Add EPUB corrector runtime, web UI, tokenizer and RKNN model
This commit is contained in:
commit
6c27e8bddb
19 changed files with 33422 additions and 0 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
*.rknn filter=lfs diff=lfs merge=lfs -text
|
||||||
159
.gitignore
vendored
Normal file
159
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
# ============================================================
|
||||||
|
# Python: ambienti, cache e file temporanei
|
||||||
|
# ============================================================
|
||||||
|
.venv/
|
||||||
|
.venv-rknn/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# File locali e configurazioni private
|
||||||
|
# ============================================================
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
config.local.json
|
||||||
|
*.secret
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# EPUB, libri e documenti
|
||||||
|
# Non pubblicare materiale protetto da copyright
|
||||||
|
# ============================================================
|
||||||
|
*.epub
|
||||||
|
*.mobi
|
||||||
|
*.azw
|
||||||
|
*.azw3
|
||||||
|
*.pdf
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Output, rapporti e memoria delle revisioni
|
||||||
|
# Possono contenere testo estratto dai libri
|
||||||
|
# ============================================================
|
||||||
|
output/
|
||||||
|
reports/
|
||||||
|
runs/
|
||||||
|
review_memory/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Dataset e artefatti di addestramento
|
||||||
|
# ============================================================
|
||||||
|
data_balanced/
|
||||||
|
pairs.jsonl
|
||||||
|
pairs_balanced.jsonl
|
||||||
|
*_dataset.jsonl
|
||||||
|
*_reference_*.npz
|
||||||
|
diagnostic_pairs.jsonl
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Modelli di training e ONNX
|
||||||
|
# Il modello RKNN pronto viene gestito separatamente più sotto
|
||||||
|
# ============================================================
|
||||||
|
models/
|
||||||
|
onnx/
|
||||||
|
*.onnx
|
||||||
|
*.safetensors
|
||||||
|
*.pt
|
||||||
|
*.pth
|
||||||
|
*.ckpt
|
||||||
|
*.bin
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# RKNN Toolkit scaricato
|
||||||
|
# ============================================================
|
||||||
|
rknn-toolkit2-*/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Modelli RKNN
|
||||||
|
# Ignora tutto tranne il modello di produzione e il suo hash
|
||||||
|
# ============================================================
|
||||||
|
rknn/*
|
||||||
|
!rknn/bert-pair-v2-eager-fp16.rknn
|
||||||
|
!rknn/bert-pair-v2-eager-fp16.sha256
|
||||||
|
!rknn/README.md
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Tokenizer necessario al modello RKNN
|
||||||
|
# Verrà copiato sotto assets/tokenizer/
|
||||||
|
# ============================================================
|
||||||
|
assets/*
|
||||||
|
!assets/tokenizer/
|
||||||
|
!assets/tokenizer/**
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Log, benchmark generati e rapporti locali
|
||||||
|
# ============================================================
|
||||||
|
*.log
|
||||||
|
nohup.out
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Archivi e pacchetti generati
|
||||||
|
# ============================================================
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
|
*.7z
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Backup e file degli editor
|
||||||
|
# ============================================================
|
||||||
|
*.bak
|
||||||
|
*.bak-*
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# File temporanei
|
||||||
|
# ============================================================
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# File hash e risultati generati fuori dalle cartelle ammesse
|
||||||
|
# ============================================================
|
||||||
|
*_applied.json
|
||||||
|
*_approved.json
|
||||||
|
*_comparison*.json
|
||||||
|
*_calibration.json
|
||||||
|
*_evaluation.json
|
||||||
|
|
||||||
|
# Script di sviluppo, training e validazione non necessari al runtime
|
||||||
|
benchmark_*.py
|
||||||
|
calibrate_*.py
|
||||||
|
check_dataset.py
|
||||||
|
check_local_tokenization.py
|
||||||
|
compare_*.py
|
||||||
|
evaluate_*.py
|
||||||
|
export_*.py
|
||||||
|
make_*.py
|
||||||
|
prepare_*.py
|
||||||
|
split_dataset.py
|
||||||
|
test_*.py
|
||||||
|
train_*.py
|
||||||
|
epub_npu_dry_run.py
|
||||||
|
epub_npu_dry_run_grouped.py
|
||||||
|
onnx-phase/
|
||||||
|
epub-npu-dry-run/
|
||||||
|
epub-review-ui/
|
||||||
|
epub-review-ui-v2/
|
||||||
|
|
||||||
|
# Copie obsolete della Web UI
|
||||||
|
epub-corrector-ui/app.py
|
||||||
|
epub-corrector-ui/static/index.html
|
||||||
11
README.md
Normal file
11
README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Fase 2: dataset per classificatore italiano
|
||||||
|
|
||||||
|
Genera coppie bilanciate `[frase A, frase B]`, dove la label indica se B è la versione corretta.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python make_training_pairs.py ~/epub-corrector/input/a.epub --pairs 20000 --output pairs.jsonl
|
||||||
|
python check_dataset.py pairs.jsonl
|
||||||
|
python split_dataset.py pairs.jsonl --outdir data
|
||||||
|
```
|
||||||
|
|
||||||
|
Nota: usare un solo libro rischia overfitting stilistico. Questa è una prova di fattibilità; per il modello finale servono più fonti italiane pulite e un test manuale separato.
|
||||||
414
apply_approved_epub_changes.py
Normal file
414
apply_approved_epub_changes.py
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse,json,tempfile
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZIP_DEFLATED,ZIP_STORED,ZipFile,ZipInfo
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
def replace_once(node,old,new):
|
||||||
|
hits=[]
|
||||||
|
for e in node.iter():
|
||||||
|
if e.text and old in e.text:hits.append((e,'text'))
|
||||||
|
if e.tail and old in e.tail:hits.append((e,'tail'))
|
||||||
|
if len(hits)!=1:raise RuntimeError(f'Attesa 1 occorrenza di {old!r}, trovate {len(hits)}')
|
||||||
|
e,field=hits[0];value=getattr(e,field)
|
||||||
|
if value.count(old)!=1:raise RuntimeError(f'{old!r} compare piu volte nello stesso nodo')
|
||||||
|
setattr(e,field,value.replace(old,new,1))
|
||||||
|
|
||||||
|
|
||||||
|
def minimal_replacement(before, after):
|
||||||
|
"""
|
||||||
|
Ricava la singola porzione modificata tra due testi.
|
||||||
|
|
||||||
|
Esempio:
|
||||||
|
before = "Puoi dido forte."
|
||||||
|
after = "Puoi dirlo forte."
|
||||||
|
|
||||||
|
Restituisce:
|
||||||
|
("dido", "dirlo")
|
||||||
|
|
||||||
|
La sostituzione può essere applicata a un nodo
|
||||||
|
testuale senza eliminare il markup XHTML.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if before == after:
|
||||||
|
raise RuntimeError(
|
||||||
|
"La riscrittura non modifica il testo"
|
||||||
|
)
|
||||||
|
|
||||||
|
prefix = 0
|
||||||
|
limit = min(len(before), len(after))
|
||||||
|
|
||||||
|
while (
|
||||||
|
prefix < limit
|
||||||
|
and before[prefix] == after[prefix]
|
||||||
|
):
|
||||||
|
prefix += 1
|
||||||
|
|
||||||
|
suffix = 0
|
||||||
|
|
||||||
|
while (
|
||||||
|
suffix < len(before) - prefix
|
||||||
|
and suffix < len(after) - prefix
|
||||||
|
and before[-1 - suffix]
|
||||||
|
== after[-1 - suffix]
|
||||||
|
):
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
if suffix:
|
||||||
|
old_part = before[prefix:-suffix]
|
||||||
|
new_part = after[prefix:-suffix]
|
||||||
|
else:
|
||||||
|
old_part = before[prefix:]
|
||||||
|
new_part = after[prefix:]
|
||||||
|
|
||||||
|
if not old_part:
|
||||||
|
raise RuntimeError(
|
||||||
|
"La riscrittura richiede un inserimento "
|
||||||
|
"puro, non gestibile in sicurezza"
|
||||||
|
)
|
||||||
|
|
||||||
|
return old_part, new_part
|
||||||
|
|
||||||
|
|
||||||
|
def iter_text_fields(node):
|
||||||
|
"""
|
||||||
|
Restituisce i campi text e tail nell'ordine
|
||||||
|
in cui appaiono nel testo XHTML.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for element in node.iter():
|
||||||
|
if element.text:
|
||||||
|
yield element, "text", element.text
|
||||||
|
|
||||||
|
for child in element:
|
||||||
|
if child.tail:
|
||||||
|
yield child, "tail", child.tail
|
||||||
|
|
||||||
|
|
||||||
|
def build_normalized_mapping(node):
|
||||||
|
"""
|
||||||
|
Costruisce lo stesso testo normalizzato usato
|
||||||
|
durante l'analisi:
|
||||||
|
|
||||||
|
re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
Per ogni carattere normalizzato conserva il
|
||||||
|
riferimento al campo XHTML e alla posizione
|
||||||
|
originale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fields = list(iter_text_fields(node))
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
mapping = []
|
||||||
|
|
||||||
|
started = False
|
||||||
|
pending_space = None
|
||||||
|
|
||||||
|
for element, field, value in fields:
|
||||||
|
for position, character in enumerate(value):
|
||||||
|
if character.isspace():
|
||||||
|
if started and pending_space is None:
|
||||||
|
pending_space = (
|
||||||
|
element,
|
||||||
|
field,
|
||||||
|
position,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pending_space is not None:
|
||||||
|
normalized.append(" ")
|
||||||
|
mapping.append(pending_space)
|
||||||
|
pending_space = None
|
||||||
|
|
||||||
|
normalized.append(character)
|
||||||
|
mapping.append((
|
||||||
|
element,
|
||||||
|
field,
|
||||||
|
position,
|
||||||
|
))
|
||||||
|
|
||||||
|
started = True
|
||||||
|
|
||||||
|
return "".join(normalized), mapping
|
||||||
|
|
||||||
|
|
||||||
|
def replace_at_normalized_offset(
|
||||||
|
node,
|
||||||
|
offset,
|
||||||
|
original,
|
||||||
|
replacement,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Sostituisce l'occorrenza che in LanguageTool
|
||||||
|
inizia esattamente a offset.
|
||||||
|
|
||||||
|
La modifica è consentita solo se tutti i
|
||||||
|
caratteri dell'originale appartengono allo
|
||||||
|
stesso campo text/tail XHTML.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized, mapping = (
|
||||||
|
build_normalized_mapping(node)
|
||||||
|
)
|
||||||
|
|
||||||
|
offset = int(offset)
|
||||||
|
end = offset + len(original)
|
||||||
|
|
||||||
|
if offset < 0 or end > len(normalized):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Offset {offset} non valido per "
|
||||||
|
f"{original!r}; lunghezza testo "
|
||||||
|
f"normalizzato: {len(normalized)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
found = normalized[offset:end]
|
||||||
|
|
||||||
|
if found != original:
|
||||||
|
# LanguageTool e il parser XHTML possono
|
||||||
|
# differire di pochi caratteri nella
|
||||||
|
# normalizzazione degli spazi.
|
||||||
|
search_radius = 4
|
||||||
|
search_start = max(
|
||||||
|
0,
|
||||||
|
offset - search_radius,
|
||||||
|
)
|
||||||
|
search_end = min(
|
||||||
|
len(normalized),
|
||||||
|
offset
|
||||||
|
+ len(original)
|
||||||
|
+ search_radius,
|
||||||
|
)
|
||||||
|
|
||||||
|
nearby = normalized[
|
||||||
|
search_start:search_end
|
||||||
|
]
|
||||||
|
|
||||||
|
relative_positions = []
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
position = nearby.find(
|
||||||
|
original,
|
||||||
|
cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
if position < 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
relative_positions.append(
|
||||||
|
position
|
||||||
|
)
|
||||||
|
cursor = position + 1
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
search_start + position
|
||||||
|
for position in relative_positions
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(candidates) != 1:
|
||||||
|
preview_start = max(
|
||||||
|
0,
|
||||||
|
offset - 40,
|
||||||
|
)
|
||||||
|
preview_end = min(
|
||||||
|
len(normalized),
|
||||||
|
end + 40,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Testo non coincidente all'offset "
|
||||||
|
f"{offset}: atteso {original!r}, "
|
||||||
|
f"trovato {found!r}; "
|
||||||
|
f"corrispondenze vicine: "
|
||||||
|
f"{candidates}. Contesto: "
|
||||||
|
f"{normalized[preview_start:preview_end]!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
corrected_offset = candidates[0]
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Offset corretto:",
|
||||||
|
repr(original),
|
||||||
|
offset,
|
||||||
|
"->",
|
||||||
|
corrected_offset,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
offset = corrected_offset
|
||||||
|
end = offset + len(original)
|
||||||
|
|
||||||
|
positions = mapping[offset:end]
|
||||||
|
|
||||||
|
if not positions:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Nessuna posizione XHTML per "
|
||||||
|
f"{original!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
first_element, first_field, first_position = (
|
||||||
|
positions[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
for element, field, _ in positions:
|
||||||
|
if (
|
||||||
|
element is not first_element
|
||||||
|
or field != first_field
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"La modifica {original!r} -> "
|
||||||
|
f"{replacement!r} attraversa più "
|
||||||
|
"nodi XHTML; applicazione bloccata"
|
||||||
|
)
|
||||||
|
|
||||||
|
actual_positions = [
|
||||||
|
position
|
||||||
|
for _, _, position in positions
|
||||||
|
]
|
||||||
|
|
||||||
|
value = getattr(
|
||||||
|
first_element,
|
||||||
|
first_field,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_position = actual_positions[-1]
|
||||||
|
|
||||||
|
if last_position < first_position:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Intervallo XHTML non valido per "
|
||||||
|
f"{original!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# L'intervallo raw può contenere più spazi,
|
||||||
|
# tabulazioni o ritorni a capo, mentre il
|
||||||
|
# rapporto usa spazi normalizzati.
|
||||||
|
raw_actual = value[
|
||||||
|
first_position:
|
||||||
|
last_position + 1
|
||||||
|
]
|
||||||
|
|
||||||
|
normalized_actual = " ".join(
|
||||||
|
raw_actual.split()
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_original = " ".join(
|
||||||
|
original.split()
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_actual != normalized_original:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Il nodo XHTML contiene "
|
||||||
|
f"{raw_actual!r}, normalizzato come "
|
||||||
|
f"{normalized_actual!r}, invece di "
|
||||||
|
f"{normalized_original!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = (
|
||||||
|
value[:first_position]
|
||||||
|
+ replacement
|
||||||
|
+ value[last_position + 1:]
|
||||||
|
)
|
||||||
|
|
||||||
|
setattr(
|
||||||
|
first_element,
|
||||||
|
first_field,
|
||||||
|
updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('source',type=Path);p.add_argument('--changes',type=Path,required=True);p.add_argument('--output',type=Path,required=True);p.add_argument('--report',type=Path,default=Path('reports/applied_changes.json'));a=p.parse_args()
|
||||||
|
if a.source.resolve()==a.output.resolve():raise SystemExit('Output e sorgente non possono coincidere')
|
||||||
|
manifest=json.loads(a.changes.read_text(encoding='utf-8'));changes=manifest.get('approved',[]);rewrites=manifest.get('sentence_rewrites',[])
|
||||||
|
if not changes and not rewrites:raise SystemExit('Nessuna modifica approvata o riscrittura')
|
||||||
|
grouped=defaultdict(list)
|
||||||
|
for c in changes:grouped[c['file']].append({**c,'change_type':'replacement'})
|
||||||
|
for c in rewrites:grouped[c['file']].append({**c,'change_type':'rewrite','original':c['original_context'],'replacement':c['rewritten_context']})
|
||||||
|
applied=[];parser=etree.XMLParser(recover=False,resolve_entities=False,no_network=True,remove_blank_text=False)
|
||||||
|
with tempfile.TemporaryDirectory(prefix='epub-review-') as td:
|
||||||
|
root=Path(td)
|
||||||
|
with ZipFile(a.source) as z:z.extractall(root)
|
||||||
|
for name,items in grouped.items():
|
||||||
|
path=root/name
|
||||||
|
if not path.exists():raise RuntimeError(f'File mancante: {name}')
|
||||||
|
tree=etree.parse(str(path),parser);pars=tree.xpath("//*[local-name()='p']")
|
||||||
|
for c in sorted(
|
||||||
|
items,
|
||||||
|
key=lambda item: (
|
||||||
|
int(item["paragraph"]),
|
||||||
|
int(item.get("offset", -1)),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
):
|
||||||
|
idx=int(c['paragraph']);node=pars[idx];before=' '.join(''.join(node.itertext()).split())
|
||||||
|
if c["change_type"] == "rewrite":
|
||||||
|
expected = " ".join(
|
||||||
|
c["original_context"].split()
|
||||||
|
)
|
||||||
|
rewritten = " ".join(
|
||||||
|
c["rewritten_context"].split()
|
||||||
|
)
|
||||||
|
|
||||||
|
if before != expected:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Il paragrafo {idx} in {name} "
|
||||||
|
"non coincide con il testo originale"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(list(node)) == 0:
|
||||||
|
# Paragrafo senza markup interno:
|
||||||
|
# la sostituzione completa è sicura.
|
||||||
|
node.text = rewritten
|
||||||
|
else:
|
||||||
|
# Paragrafo con corsivi, link o span:
|
||||||
|
# applica soltanto la differenza minima.
|
||||||
|
old_part, new_part = (
|
||||||
|
minimal_replacement(
|
||||||
|
before,
|
||||||
|
rewritten,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
replace_once(
|
||||||
|
node,
|
||||||
|
old_part,
|
||||||
|
new_part,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if c["original"] not in before:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{c['original']!r} non trovato "
|
||||||
|
f"in {name}, paragrafo {idx}"
|
||||||
|
)
|
||||||
|
|
||||||
|
correction_offset = c.get("offset")
|
||||||
|
|
||||||
|
if correction_offset is None:
|
||||||
|
# Compatibilità con manifesti precedenti
|
||||||
|
# che non contengono l'offset.
|
||||||
|
replace_once(
|
||||||
|
node,
|
||||||
|
c["original"],
|
||||||
|
c["replacement"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
replace_at_normalized_offset(
|
||||||
|
node=node,
|
||||||
|
offset=int(correction_offset),
|
||||||
|
original=c["original"],
|
||||||
|
replacement=c["replacement"],
|
||||||
|
)
|
||||||
|
after=' '.join(''.join(node.itertext()).split());applied.append({**c,'before':before,'after':after})
|
||||||
|
tree.write(str(path),encoding='utf-8',xml_declaration=True,pretty_print=False)
|
||||||
|
a.output.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
with ZipFile(a.output,'w') as out:
|
||||||
|
mt=root/'mimetype'
|
||||||
|
if mt.exists():
|
||||||
|
info=ZipInfo('mimetype');info.compress_type=ZIP_STORED;out.writestr(info,mt.read_bytes())
|
||||||
|
for f in sorted(root.rglob('*')):
|
||||||
|
if f.is_file() and f.relative_to(root).as_posix()!='mimetype':out.write(f,f.relative_to(root).as_posix(),compress_type=ZIP_DEFLATED)
|
||||||
|
a.report.parent.mkdir(parents=True,exist_ok=True);a.report.write_text(json.dumps({'source':str(a.source),'output':str(a.output),'changes_applied':len(applied),'applied':applied},ensure_ascii=False,indent=2),encoding='utf-8')
|
||||||
|
print('Nuovo EPUB:',a.output);print('Modifiche applicate:',len(applied));print('Rapporto:',a.report)
|
||||||
|
if __name__=='__main__':main()
|
||||||
31266
assets/tokenizer/tokenizer.json
Normal file
31266
assets/tokenizer/tokenizer.json
Normal file
File diff suppressed because it is too large
Load diff
16
assets/tokenizer/tokenizer_config.json
Normal file
16
assets/tokenizer/tokenizer_config.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"backend": "tokenizers",
|
||||||
|
"cls_token": "[CLS]",
|
||||||
|
"do_lower_case": false,
|
||||||
|
"is_local": true,
|
||||||
|
"local_files_only": false,
|
||||||
|
"mask_token": "[MASK]",
|
||||||
|
"max_len": 512,
|
||||||
|
"model_max_length": 512,
|
||||||
|
"pad_token": "[PAD]",
|
||||||
|
"sep_token": "[SEP]",
|
||||||
|
"strip_accents": null,
|
||||||
|
"tokenize_chinese_chars": true,
|
||||||
|
"tokenizer_class": "BertTokenizer",
|
||||||
|
"unk_token": "[UNK]"
|
||||||
|
}
|
||||||
13
config.example.json
Normal file
13
config.example.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"project_dir": "/home/nvme/epub-gec-experiment",
|
||||||
|
"input_dir": "/home/orangepi/epub-corrector/input",
|
||||||
|
"output_dir": "/home/nvme/epub-gec-experiment/output",
|
||||||
|
"model": "/home/nvme/epub-gec-experiment/rknn/bert-pair-v2-eager-fp16.rknn",
|
||||||
|
"tokenizer": "/home/nvme/epub-gec-experiment/assets/tokenizer/tokenizer.json",
|
||||||
|
"memory": "/home/nvme/epub-gec-experiment/review_memory/auto_memory.json",
|
||||||
|
"languagetool_url": "http://127.0.0.1:8010/v2/check",
|
||||||
|
"web_ui_host": "0.0.0.0",
|
||||||
|
"web_ui_port": 8090,
|
||||||
|
"auto_apply_threshold": 0.999,
|
||||||
|
"minimum_margin": 0.002
|
||||||
|
}
|
||||||
219
epub-corrector-ui-app/app.py
Normal file
219
epub-corrector-ui-app/app.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import json, subprocess, threading, uuid, shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import Flask, request, jsonify, send_from_directory
|
||||||
|
|
||||||
|
BASE=Path('/home/nvme/epub-gec-experiment')
|
||||||
|
INPUT=Path('/home/orangepi/epub-corrector/input')
|
||||||
|
RUNS=BASE/'runs'/'ui'
|
||||||
|
MEMORY_DIR=BASE/'review_memory'
|
||||||
|
MEMORY_FILE=MEMORY_DIR/'auto_memory.json'
|
||||||
|
|
||||||
|
RUNS.mkdir(parents=True, exist_ok=True)
|
||||||
|
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def empty_memory():
|
||||||
|
return {
|
||||||
|
'approved': [],
|
||||||
|
'sentence_rewrites': [],
|
||||||
|
'preserved': [],
|
||||||
|
'rejected': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_memory():
|
||||||
|
if not MEMORY_FILE.exists():
|
||||||
|
return empty_memory()
|
||||||
|
|
||||||
|
data = json.loads(
|
||||||
|
MEMORY_FILE.read_text(encoding='utf-8')
|
||||||
|
)
|
||||||
|
|
||||||
|
memory = empty_memory()
|
||||||
|
|
||||||
|
for section in memory:
|
||||||
|
memory[section] = data.get(section, [])
|
||||||
|
|
||||||
|
return memory
|
||||||
|
|
||||||
|
|
||||||
|
def merge_memory(new_decisions):
|
||||||
|
memory = load_memory()
|
||||||
|
|
||||||
|
key_functions = {
|
||||||
|
'approved': lambda item: (
|
||||||
|
item.get('original', '').casefold(),
|
||||||
|
item.get('replacement', '').casefold(),
|
||||||
|
),
|
||||||
|
'sentence_rewrites': lambda item: (
|
||||||
|
item.get('original_context', ''),
|
||||||
|
item.get('rewritten_context', ''),
|
||||||
|
),
|
||||||
|
'preserved': lambda item: (
|
||||||
|
item.get('value', '').casefold()
|
||||||
|
),
|
||||||
|
'rejected': lambda item: (
|
||||||
|
item.get('original', '').casefold(),
|
||||||
|
item.get('replacement', '').casefold(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
for section, key_function in key_functions.items():
|
||||||
|
merged = {}
|
||||||
|
|
||||||
|
for item in memory[section]:
|
||||||
|
key = key_function(item)
|
||||||
|
|
||||||
|
if key:
|
||||||
|
merged[key] = item
|
||||||
|
|
||||||
|
for item in new_decisions.get(section, []):
|
||||||
|
key = key_function(item)
|
||||||
|
|
||||||
|
if key:
|
||||||
|
merged[key] = item
|
||||||
|
|
||||||
|
memory[section] = list(merged.values())
|
||||||
|
|
||||||
|
# Una successiva approvazione prevale
|
||||||
|
# sull'eventuale rifiuto della stessa coppia.
|
||||||
|
approved_pairs = {
|
||||||
|
key_functions['approved'](item)
|
||||||
|
for item in memory['approved']
|
||||||
|
}
|
||||||
|
|
||||||
|
memory['rejected'] = [
|
||||||
|
item
|
||||||
|
for item in memory['rejected']
|
||||||
|
if key_functions['rejected'](item)
|
||||||
|
not in approved_pairs
|
||||||
|
]
|
||||||
|
|
||||||
|
temporary = MEMORY_FILE.with_suffix('.tmp')
|
||||||
|
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(
|
||||||
|
memory,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
temporary.replace(MEMORY_FILE)
|
||||||
|
|
||||||
|
return memory
|
||||||
|
app=Flask(__name__, static_folder='static')
|
||||||
|
jobs={}
|
||||||
|
|
||||||
|
def run_job(job_id, epub):
|
||||||
|
job=jobs[job_id]
|
||||||
|
report=RUNS/f'{job_id}.json'; log=RUNS/f'{job_id}.log'
|
||||||
|
lt_url = "".join([
|
||||||
|
"http:",
|
||||||
|
"//",
|
||||||
|
"127.0.0.1",
|
||||||
|
":8010",
|
||||||
|
"/v2/check",
|
||||||
|
])
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
str(BASE / ".venv-rknn/bin/python"),
|
||||||
|
str(BASE / "epub_npu_dry_run_grouped_v2.py"),
|
||||||
|
str(epub),
|
||||||
|
"--memory",
|
||||||
|
str(MEMORY_FILE),
|
||||||
|
"--threshold",
|
||||||
|
"0.999",
|
||||||
|
"--min-margin",
|
||||||
|
"0.002",
|
||||||
|
"--languagetool-url",
|
||||||
|
lt_url,
|
||||||
|
"--output",
|
||||||
|
str(report),
|
||||||
|
]
|
||||||
|
job.update(status='running',report=str(report),log=str(log),cmd=cmd)
|
||||||
|
with log.open('w') as f:
|
||||||
|
p=subprocess.Popen(cmd,stdout=f,stderr=subprocess.STDOUT,text=True)
|
||||||
|
job['pid']=p.pid; rc=p.wait()
|
||||||
|
job['status']='done' if rc==0 else 'failed'; job['returncode']=rc
|
||||||
|
|
||||||
|
@app.get('/')
|
||||||
|
def index(): return send_from_directory('static','index.html')
|
||||||
|
|
||||||
|
@app.get('/api/books')
|
||||||
|
def books(): return jsonify([{'name':p.name,'path':str(p)} for p in sorted(INPUT.glob('*.epub'))])
|
||||||
|
|
||||||
|
@app.post('/api/start')
|
||||||
|
def start():
|
||||||
|
data=request.get_json(); epub=Path(data['path'])
|
||||||
|
if epub.parent.resolve()!=INPUT.resolve() or not epub.exists(): return jsonify(error='EPUB non valido'),400
|
||||||
|
jid=uuid.uuid4().hex[:10]; jobs[jid]={'id':jid,'book':str(epub),'status':'queued'}
|
||||||
|
threading.Thread(target=run_job,args=(jid,epub),daemon=True).start(); return jsonify(jobs[jid])
|
||||||
|
|
||||||
|
@app.get('/api/job/<jid>')
|
||||||
|
def job(jid):
|
||||||
|
j=jobs.get(jid)
|
||||||
|
if not j:return jsonify(error='Job non trovato'),404
|
||||||
|
if j.get('log') and Path(j['log']).exists(): j['tail']='\n'.join(Path(j['log']).read_text(errors='replace').splitlines()[-25:])
|
||||||
|
return jsonify(j)
|
||||||
|
|
||||||
|
@app.get('/api/report/<jid>')
|
||||||
|
def report(jid):
|
||||||
|
j=jobs.get(jid); p=Path(j.get('report','')) if j else None
|
||||||
|
if not p or not p.exists(): return jsonify(error='Rapporto non pronto'),404
|
||||||
|
data=json.loads(p.read_text()); items=[]
|
||||||
|
for i,x in enumerate(data.get('groups',[])):
|
||||||
|
if x.get('decision') in {'AUTO_APPLY','REVIEW_HIGH','REVIEW_AMBIGUOUS'} and x.get('best_proposal'):
|
||||||
|
items.append({'id':i,'decision':x['decision'],'file':x['file'],'paragraph':x['paragraph'],'offset':x['offset'],'original':x['original'],'proposal':x['best_proposal'],'probability':x.get('best_probability'),'margin':x.get('margin'),'context':x['context']})
|
||||||
|
return jsonify(items=items,summary=data.get('summary',{}))
|
||||||
|
|
||||||
|
@app.post('/api/apply/<jid>')
|
||||||
|
def apply(jid):
|
||||||
|
j=jobs.get(jid)
|
||||||
|
if not j:return jsonify(error='Job non trovato'),404
|
||||||
|
decisions = request.get_json()
|
||||||
|
|
||||||
|
manifest = RUNS / f'{jid}_approved.json'
|
||||||
|
|
||||||
|
manifest.write_text(
|
||||||
|
json.dumps(
|
||||||
|
decisions,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
memory = merge_memory(decisions)
|
||||||
|
|
||||||
|
out = (
|
||||||
|
BASE
|
||||||
|
/ 'output'
|
||||||
|
/ f"{Path(j['book']).stem}_ui_reviewed.epub"
|
||||||
|
)
|
||||||
|
|
||||||
|
rep = RUNS / f'{jid}_applied.json'
|
||||||
|
cmd=[str(BASE/'.venv-rknn/bin/python'),str(BASE/'apply_approved_epub_changes.py'),j['book'],'--changes',str(manifest),'--output',str(out),'--report',str(rep)]
|
||||||
|
r=subprocess.run(cmd,capture_output=True,text=True)
|
||||||
|
if r.returncode:return jsonify(error=r.stdout+r.stderr),400
|
||||||
|
return jsonify(
|
||||||
|
output=str(out),
|
||||||
|
report=str(rep),
|
||||||
|
memory=str(MEMORY_FILE),
|
||||||
|
memory_counts={
|
||||||
|
key: len(value)
|
||||||
|
for key, value in memory.items()
|
||||||
|
},
|
||||||
|
message=r.stdout,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post('/api/languagetool/<action>')
|
||||||
|
def lt(action):
|
||||||
|
if action not in {'start','stop','restart'}:return jsonify(error='Azione non valida'),400
|
||||||
|
r=subprocess.run(['sudo','docker',action,'languagetool-epub'],capture_output=True,text=True)
|
||||||
|
return jsonify(ok=r.returncode==0,output=r.stdout+r.stderr)
|
||||||
|
|
||||||
|
app.run(host='0.0.0.0',port=8090,threaded=True)
|
||||||
12
epub-corrector-ui-app/static/index.html
Normal file
12
epub-corrector-ui-app/static/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>EPUB Corrector</title><style>body{font:15px system-ui;margin:0;background:#f3f5f8;color:#172033}header{background:#101828;color:white;padding:18px}main{max-width:1100px;margin:auto;padding:18px}button,select,input,textarea{padding:9px;border:1px solid #bbb;border-radius:7px}button{cursor:pointer;font-weight:650}.primary{background:#1570ef;color:white}.card{background:white;border:1px solid #ddd;border-radius:10px;padding:14px;margin:12px 0}.context{white-space:pre-wrap;background:#f8fafc;padding:10px}.row{display:flex;gap:8px;flex-wrap:wrap}.rewrite{width:100%;min-height:85px}.log{white-space:pre-wrap;background:#111827;color:#d1fae5;padding:12px;max-height:300px;overflow:auto}.ok{background:#d1fadf}.bad{background:#fee4e2}</style></head><body><header><h1>EPUB Corrector</h1><div class="row"><button onclick="lt('start')">Avvia LanguageTool</button><button onclick="lt('stop')">Ferma LanguageTool</button><button onclick="lt('restart')">Riavvia LanguageTool</button></div></header><main><section class="card"><h2>1. Seleziona il libro</h2><select id="book"></select> <button class="primary" onclick="start()">Analizza</button><div id="status"></div><pre id="log" class="log"></pre></section><section id="review"></section><button id="apply" class="primary" style="display:none" onclick="applyAll()">Crea EPUB corretto</button></main><script>
|
||||||
|
let jid=null,items=[];const decisions={approved:[],sentence_rewrites:[],preserved:[],rejected:[]};
|
||||||
|
async function api(url,opt){const r=await fetch(url,opt);const x=await r.json();if(!r.ok)throw Error(x.error||'Errore');return x}
|
||||||
|
async function load(){const b=await api('/api/books');document.querySelector('#book').innerHTML=b.map(x=>`<option value="${x.path}">${x.name}</option>`).join('')}
|
||||||
|
async function lt(a){alert((await api('/api/languagetool/'+a,{method:'POST'})).output||'OK')}
|
||||||
|
async function start(){const x=await api('/api/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:book.value})});jid=x.id;poll()}
|
||||||
|
async function poll(){const x=await api('/api/job/'+jid);status.textContent='Stato: '+x.status;log.textContent=x.tail||'';if(x.status==='done')return loadReport();if(x.status==='failed')return;setTimeout(poll,2000)}
|
||||||
|
async function loadReport(){const x=await api('/api/report/'+jid);items=x.items;review.innerHTML='<h2>2. Revisiona</h2>'+items.map(card).join('');apply.style.display='block'}
|
||||||
|
function card(x){return `<article class="card" id="c${x.id}"><b>${x.decision}</b> · P=${(x.probability||0).toFixed(6)}<h3><s>${x.original}</s> → ${x.proposal}</h3><div class="context">${x.context}</div><textarea class="rewrite" placeholder="Riscrivi l'intera frase/paragrafo"></textarea><input class="word" value="${x.original}" placeholder="Parola da ricordare"><div class="row"><button onclick="choose(${x.id},'approve')">Approva proposta</button><button onclick="choose(${x.id},'rewrite')">Salva riscrittura</button><button onclick="choose(${x.id},'preserve')">Mantieni e ricorda</button><button onclick="choose(${x.id},'reject')">Rifiuta</button></div></article>`}
|
||||||
|
function choose(id,a){const x=items.find(v=>v.id===id),el=document.querySelector('#c'+id);el.classList.remove('ok','bad');if(a==='approve'){decisions.approved.push({file:x.file,paragraph:x.paragraph,offset:x.offset,original:x.original,replacement:x.proposal});el.classList.add('ok')}if(a==='rewrite'){const t=el.querySelector('.rewrite').value.trim();if(!t)return alert('Inserisci la riscrittura');decisions.sentence_rewrites.push({file:x.file,paragraph:x.paragraph,original_context:x.context,rewritten_context:t});el.classList.add('ok')}if(a==='preserve'){decisions.preserved.push({value:el.querySelector('.word').value.trim(),source:'ui'});el.classList.add('ok')}if(a==='reject'){decisions.rejected.push({original:x.original,replacement:x.proposal});el.classList.add('bad')}}
|
||||||
|
async function applyAll(){const x=await api('/api/apply/'+jid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(decisions)});alert('Creato: '+x.output)}load();
|
||||||
|
</script></body></html>
|
||||||
5
epub-corrector-ui/README.md
Normal file
5
epub-corrector-ui/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# EPUB Corrector UI
|
||||||
|
|
||||||
|
Interfaccia web unica per avviare LanguageTool, analizzare un EPUB, approvare, riscrivere, preservare parole e creare una copia corretta.
|
||||||
|
|
||||||
|
Installare con `./install.sh`, poi aprire `http://IP_ORANGEPI:8090`.
|
||||||
12
epub-corrector-ui/epub-corrector-ui.service
Normal file
12
epub-corrector-ui/epub-corrector-ui.service
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[Unit]
|
||||||
|
Description=EPUB Corrector UI
|
||||||
|
After=network.target docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=orangepi
|
||||||
|
WorkingDirectory=/home/nvme/epub-gec-experiment/epub-corrector-ui
|
||||||
|
ExecStart=/home/nvme/epub-gec-experiment/.venv-rknn/bin/python app.py
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
76
epub-corrector-ui/install.sh
Executable file
76
epub-corrector-ui/install.sh
Executable file
|
|
@ -0,0 +1,76 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="/home/nvme/epub-gec-experiment"
|
||||||
|
INSTALL_DIR="$PROJECT_DIR/epub-corrector-ui-app"
|
||||||
|
VENV="$PROJECT_DIR/.venv-rknn"
|
||||||
|
SERVICE_NAME="epub-corrector-ui"
|
||||||
|
|
||||||
|
echo "Directory sorgente: $SOURCE_DIR"
|
||||||
|
echo "Directory installazione: $INSTALL_DIR"
|
||||||
|
|
||||||
|
test -f "$SOURCE_DIR/app.py" || {
|
||||||
|
echo "ERRORE: app.py non trovato in $SOURCE_DIR"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
test -f "$SOURCE_DIR/static/index.html" || {
|
||||||
|
echo "ERRORE: static/index.html non trovato"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
test -x "$VENV/bin/python" || {
|
||||||
|
echo "ERRORE: ambiente Python non trovato: $VENV"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$INSTALL_DIR/static"
|
||||||
|
|
||||||
|
cp -v \
|
||||||
|
"$SOURCE_DIR/app.py" \
|
||||||
|
"$INSTALL_DIR/app.py"
|
||||||
|
|
||||||
|
cp -v \
|
||||||
|
"$SOURCE_DIR/static/index.html" \
|
||||||
|
"$INSTALL_DIR/static/index.html"
|
||||||
|
|
||||||
|
"$VENV/bin/python" -m pip install \
|
||||||
|
flask \
|
||||||
|
requests \
|
||||||
|
lxml \
|
||||||
|
tokenizers
|
||||||
|
|
||||||
|
cat > /tmp/epub-corrector-ui.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=EPUB Corrector Web UI
|
||||||
|
After=network.target docker.service
|
||||||
|
Wants=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=orangepi
|
||||||
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
ExecStart=$VENV/bin/python $INSTALL_DIR/app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo cp -v \
|
||||||
|
/tmp/epub-corrector-ui.service \
|
||||||
|
/etc/systemd/system/$SERVICE_NAME.service
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable "$SERVICE_NAME"
|
||||||
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== STATO SERVIZIO ==="
|
||||||
|
|
||||||
|
sudo systemctl status \
|
||||||
|
"$SERVICE_NAME" \
|
||||||
|
--no-pager
|
||||||
540
epub_npu_dry_run_grouped_v2.py
Executable file
540
epub_npu_dry_run_grouped_v2.py
Executable file
|
|
@ -0,0 +1,540 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse, json, re, subprocess, time
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import requests
|
||||||
|
from lxml import etree
|
||||||
|
from rknnlite.api import RKNNLite
|
||||||
|
from tokenizers import Tokenizer
|
||||||
|
|
||||||
|
WORD_RE = re.compile(r"^[A-Za-zÀ-ÖØ-öø-ÿ'’]+$")
|
||||||
|
URL_RE = re.compile(r"(?:https?://|www\.|\b\S+@\S+\b)", re.I)
|
||||||
|
HARD_FILTERS = {"punctuation_or_spacing", "url_or_email", "contains_number", "acronym_or_all_caps", "probable_proper_noun", "case_only"}
|
||||||
|
SOFT_FILTERS = {"not_single_word", "too_short"}
|
||||||
|
PUNCT_RULES = {"WHITESPACE_RULE", "COMMA_PARENTHESIS_WHITESPACE", "UPPERCASE_SENTENCE_START"}
|
||||||
|
|
||||||
|
|
||||||
|
def softmax(logits):
|
||||||
|
x = logits - np.max(logits); e = np.exp(x); return e / e.sum()
|
||||||
|
|
||||||
|
|
||||||
|
def hunspell_valid(word):
|
||||||
|
try:
|
||||||
|
p = subprocess.run(["hunspell", "-d", "it_IT", "-l"], input=word + "\n", text=True, capture_output=True, timeout=5)
|
||||||
|
return not p.stdout.strip()
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_paragraphs(epub):
|
||||||
|
parser = etree.XMLParser(recover=True, resolve_entities=False, no_network=True)
|
||||||
|
with ZipFile(epub) as z:
|
||||||
|
for name in sorted(z.namelist()):
|
||||||
|
if not name.lower().endswith((".xhtml", ".html", ".htm")): continue
|
||||||
|
try: root = etree.fromstring(z.read(name), parser)
|
||||||
|
except Exception: continue
|
||||||
|
for index, node in enumerate(root.xpath("//*[local-name()='p']")):
|
||||||
|
text = re.sub(r"\s+", " ", "".join(node.itertext())).strip()
|
||||||
|
if text: yield name, index, text
|
||||||
|
|
||||||
|
|
||||||
|
# Forme non accentate che possono essere parole valide,
|
||||||
|
# ma che in un contesto narrativo possono richiedere
|
||||||
|
# l'accento del passato remoto.
|
||||||
|
#
|
||||||
|
# Queste associazioni generano solo candidati:
|
||||||
|
# la decisione resta al classificatore RKNN.
|
||||||
|
CONTEXTUAL_ACCENTS = {
|
||||||
|
"cambio": "cambiò",
|
||||||
|
"fini": "finì",
|
||||||
|
"entro": "entrò",
|
||||||
|
"torno": "tornò",
|
||||||
|
"lascio": "lasciò",
|
||||||
|
"grido": "gridò",
|
||||||
|
"salto": "saltò",
|
||||||
|
"sparo": "sparò",
|
||||||
|
"mando": "mandò",
|
||||||
|
"guardo": "guardò",
|
||||||
|
"chiamo": "chiamò",
|
||||||
|
"comincio": "cominciò",
|
||||||
|
"continuo": "continuò",
|
||||||
|
"racconto": "raccontò",
|
||||||
|
"domando": "domandò",
|
||||||
|
"rispondo": "rispondò",
|
||||||
|
}
|
||||||
|
|
||||||
|
# "rispondò" non è italiano corretto: lo rimuoviamo
|
||||||
|
# esplicitamente per impedire che una tabella futura
|
||||||
|
# possa introdurlo accidentalmente.
|
||||||
|
CONTEXTUAL_ACCENTS.pop("rispondo", None)
|
||||||
|
|
||||||
|
|
||||||
|
def contextual_accent_matches(text):
|
||||||
|
"""
|
||||||
|
Genera match compatibili con il formato LanguageTool.
|
||||||
|
|
||||||
|
Usa confini di parola, quindi non trova per esempio
|
||||||
|
'porto' dentro 'comportò'. Le forme con iniziale
|
||||||
|
maiuscola vengono ignorate per ridurre il rischio
|
||||||
|
sui nomi propri.
|
||||||
|
"""
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
|
||||||
|
for source, proposed in CONTEXTUAL_ACCENTS.items():
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"(?<![A-Za-zÀ-ÖØ-öø-ÿ'’])"
|
||||||
|
rf"{re.escape(source)}"
|
||||||
|
rf"(?![A-Za-zÀ-ÖØ-öø-ÿ'’])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
for found in pattern.finditer(text):
|
||||||
|
original = found.group(0)
|
||||||
|
|
||||||
|
if original[:1].isupper():
|
||||||
|
continue
|
||||||
|
|
||||||
|
matches.append({
|
||||||
|
"message": (
|
||||||
|
"Possibile accento contestuale "
|
||||||
|
"del passato remoto."
|
||||||
|
),
|
||||||
|
"shortMessage": "Accento contestuale",
|
||||||
|
"offset": found.start(),
|
||||||
|
"length": len(original),
|
||||||
|
"replacements": [
|
||||||
|
{"value": proposed}
|
||||||
|
],
|
||||||
|
"rule": {
|
||||||
|
"id": "CONTEXTUAL_FINAL_ACCENT",
|
||||||
|
"description": (
|
||||||
|
"Possibile passato remoto "
|
||||||
|
"con accento finale"
|
||||||
|
),
|
||||||
|
"issueType": "misspelling",
|
||||||
|
"category": {
|
||||||
|
"id": "CONTEXTUAL_ACCENTS",
|
||||||
|
"name": "Accenti contestuali",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def merge_matches(primary, generated):
|
||||||
|
"""
|
||||||
|
Unisce i match evitando duplicati con la stessa
|
||||||
|
posizione, lunghezza, regola e proposta.
|
||||||
|
"""
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for match in [*primary, *generated]:
|
||||||
|
replacements = tuple(
|
||||||
|
replacement.get("value", "")
|
||||||
|
for replacement in match.get(
|
||||||
|
"replacements",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
key = (
|
||||||
|
int(match.get("offset", 0)),
|
||||||
|
int(match.get("length", 0)),
|
||||||
|
match.get("rule", {}).get("id", ""),
|
||||||
|
replacements,
|
||||||
|
)
|
||||||
|
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen.add(key)
|
||||||
|
merged.append(match)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def language_tool(text, url, timeout):
|
||||||
|
r = requests.post(url, data={"language": "it-IT", "text": text}, timeout=timeout)
|
||||||
|
r.raise_for_status(); return r.json().get("matches", [])
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_filters(original, proposed, paragraph, offset, rule):
|
||||||
|
reasons = []
|
||||||
|
if rule in PUNCT_RULES: reasons.append("punctuation_or_spacing")
|
||||||
|
if not WORD_RE.fullmatch(original) or not WORD_RE.fullmatch(proposed): reasons.append("not_single_word")
|
||||||
|
if URL_RE.search(original) or URL_RE.search(proposed): reasons.append("url_or_email")
|
||||||
|
if any(c.isdigit() for c in original + proposed): reasons.append("contains_number")
|
||||||
|
if original.isupper() or proposed.isupper(): reasons.append("acronym_or_all_caps")
|
||||||
|
if original[:1].isupper() and offset > 0:
|
||||||
|
before = paragraph[:offset].rstrip()
|
||||||
|
if before and before[-1] not in ".!?…«“\"": reasons.append("probable_proper_noun")
|
||||||
|
if len(original) < 3 or len(proposed) < 3: reasons.append("too_short")
|
||||||
|
if original.casefold() == proposed.casefold(): reasons.append("case_only")
|
||||||
|
return sorted(set(reasons))
|
||||||
|
|
||||||
|
|
||||||
|
def local_context(
|
||||||
|
paragraph,
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
proposed,
|
||||||
|
radius=120,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Costruisce due finestre locali identiche,
|
||||||
|
salvo la sostituzione interessata.
|
||||||
|
|
||||||
|
radius è espresso in caratteri; 320 prima
|
||||||
|
e 320 dopo sono normalmente compatibili
|
||||||
|
con il limite di 128 token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
start = max(0, offset - radius)
|
||||||
|
end = min(
|
||||||
|
len(paragraph),
|
||||||
|
offset + length + radius,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cerca di partire da un confine di parola.
|
||||||
|
if start > 0:
|
||||||
|
next_space = paragraph.find(" ", start)
|
||||||
|
|
||||||
|
if (
|
||||||
|
next_space != -1
|
||||||
|
and next_space < offset
|
||||||
|
):
|
||||||
|
start = next_space + 1
|
||||||
|
|
||||||
|
# Evita di terminare nel mezzo di una parola.
|
||||||
|
if end < len(paragraph):
|
||||||
|
previous_space = paragraph.rfind(
|
||||||
|
" ",
|
||||||
|
offset + length,
|
||||||
|
end,
|
||||||
|
)
|
||||||
|
|
||||||
|
if previous_space > offset + length:
|
||||||
|
end = previous_space
|
||||||
|
|
||||||
|
original_context = paragraph[start:end]
|
||||||
|
|
||||||
|
relative_offset = offset - start
|
||||||
|
|
||||||
|
proposed_context = (
|
||||||
|
original_context[:relative_offset]
|
||||||
|
+ proposed
|
||||||
|
+ original_context[
|
||||||
|
relative_offset + length:
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return original_context, proposed_context
|
||||||
|
|
||||||
|
|
||||||
|
def inputs_for(tokenizer, original_context, proposed_context):
|
||||||
|
x = tokenizer.encode(original_context, proposed_context)
|
||||||
|
return [np.asarray([x.ids], np.int64), np.asarray([x.attention_mask], np.int64), np.asarray([x.type_ids], np.int64)]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Dry-run EPUB con alternative raggruppate; non modifica il libro.")
|
||||||
|
ap.add_argument("epub", type=Path)
|
||||||
|
ap.add_argument("--model", type=Path, default=Path("rknn/bert-pair-v2-eager-fp16.rknn"))
|
||||||
|
ap.add_argument("--tokenizer", type=Path, default=Path("onnx/bert-pair-v2/tokenizer/tokenizer.json"))
|
||||||
|
ap.add_argument("--languagetool-url", default="http://127.0.0.1:8010/v2/check")
|
||||||
|
ap.add_argument("--threshold", type=float, default=0.999)
|
||||||
|
ap.add_argument("--min-margin", type=float, default=0.002)
|
||||||
|
ap.add_argument("--review-threshold", type=float, default=0.90)
|
||||||
|
ap.add_argument("--max-alternatives", type=int, default=10)
|
||||||
|
ap.add_argument(
|
||||||
|
"--start-paragraph",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Indice globale iniziale, a partire da 1",
|
||||||
|
)
|
||||||
|
ap.add_argument("--max-paragraphs", type=int, default=0)
|
||||||
|
ap.add_argument("--output", type=Path)
|
||||||
|
ap.add_argument(
|
||||||
|
"--memory",
|
||||||
|
type=Path,
|
||||||
|
help=(
|
||||||
|
"JSON esportato dalla revisione precedente "
|
||||||
|
"con approvazioni, rifiuti e parole preservate"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ap.add_argument("--timeout", type=int, default=60)
|
||||||
|
a = ap.parse_args()
|
||||||
|
for p in (a.epub, a.model, a.tokenizer):
|
||||||
|
if not p.exists():
|
||||||
|
raise SystemExit(f"File non trovato: {p}")
|
||||||
|
|
||||||
|
memory = {
|
||||||
|
"approved": [],
|
||||||
|
"sentence_rewrites": [],
|
||||||
|
"preserved": [],
|
||||||
|
"rejected": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.memory:
|
||||||
|
if not a.memory.exists():
|
||||||
|
raise SystemExit(
|
||||||
|
f"Memoria non trovata: {a.memory}"
|
||||||
|
)
|
||||||
|
|
||||||
|
loaded_memory = json.loads(
|
||||||
|
a.memory.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in memory:
|
||||||
|
memory[key] = loaded_memory.get(key, [])
|
||||||
|
|
||||||
|
preserved_values = {
|
||||||
|
item.get("value", "").strip().casefold()
|
||||||
|
for item in memory["preserved"]
|
||||||
|
if item.get("value", "").strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
approved_pairs = {
|
||||||
|
(
|
||||||
|
item.get("original", "").strip().casefold(),
|
||||||
|
item.get("replacement", "").strip().casefold(),
|
||||||
|
)
|
||||||
|
for item in memory["approved"]
|
||||||
|
if item.get("original")
|
||||||
|
and item.get("replacement")
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected_pairs = {
|
||||||
|
(
|
||||||
|
item.get("original", "").strip().casefold(),
|
||||||
|
item.get("replacement", "").strip().casefold(),
|
||||||
|
)
|
||||||
|
for item in memory["rejected"]
|
||||||
|
if item.get("original")
|
||||||
|
and item.get("replacement")
|
||||||
|
}
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Memoria:",
|
||||||
|
len(approved_pairs),
|
||||||
|
"approvate,",
|
||||||
|
len(rejected_pairs),
|
||||||
|
"rifiutate,",
|
||||||
|
len(preserved_values),
|
||||||
|
"preservate",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
out = a.output or Path("reports") / f"{a.epub.stem}_npu_grouped.json"; out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
tok = Tokenizer.from_file(str(a.tokenizer)); tok.enable_truncation(max_length=128); tok.enable_padding(length=128, pad_id=0, pad_token="[PAD]")
|
||||||
|
npu = RKNNLite(verbose=False)
|
||||||
|
if npu.load_rknn(str(a.model)) != 0 or npu.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO) != 0: raise SystemExit("Inizializzazione RKNN fallita")
|
||||||
|
|
||||||
|
groups, paragraph_count, times = [], 0, []
|
||||||
|
try:
|
||||||
|
global_index = 0
|
||||||
|
|
||||||
|
for file_name, para_index, paragraph in extract_paragraphs(a.epub):
|
||||||
|
global_index += 1
|
||||||
|
|
||||||
|
if global_index < a.start_paragraph:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
a.max_paragraphs
|
||||||
|
and paragraph_count >= a.max_paragraphs
|
||||||
|
):
|
||||||
|
break
|
||||||
|
|
||||||
|
paragraph_count += 1
|
||||||
|
try:
|
||||||
|
matches = language_tool(
|
||||||
|
paragraph,
|
||||||
|
a.languagetool_url,
|
||||||
|
a.timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
matches = merge_matches(
|
||||||
|
matches,
|
||||||
|
contextual_accent_matches(
|
||||||
|
paragraph
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
groups.append({"file": file_name, "paragraph": para_index, "decision": "ERROR", "reason": f"LanguageTool: {exc}"}); continue
|
||||||
|
|
||||||
|
for match in matches:
|
||||||
|
offset, length = int(match.get("offset", 0)), int(match.get("length", 0)); original = paragraph[offset:offset+length]
|
||||||
|
rule = match.get("rule", {}).get("id", "")
|
||||||
|
proposals = list(dict.fromkeys(r.get("value", "").strip() for r in match.get("replacements", []) if r.get("value", "").strip()))[:a.max_alternatives]
|
||||||
|
base = {
|
||||||
|
"file": file_name,
|
||||||
|
"paragraph": para_index,
|
||||||
|
"offset": offset,
|
||||||
|
"original": original,
|
||||||
|
"context": paragraph,
|
||||||
|
"rule": rule,
|
||||||
|
"message": match.get("message", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
original_key = original.strip().casefold()
|
||||||
|
|
||||||
|
if original_key in preserved_values:
|
||||||
|
groups.append({
|
||||||
|
**base,
|
||||||
|
"decision": "KEEP_REMEMBERED",
|
||||||
|
"reason":
|
||||||
|
"preserved_in_previous_review",
|
||||||
|
"original_hunspell_valid":
|
||||||
|
hunspell_valid(original),
|
||||||
|
"best_proposal": None,
|
||||||
|
"best_probability": None,
|
||||||
|
"second_proposal": None,
|
||||||
|
"second_probability": None,
|
||||||
|
"margin": None,
|
||||||
|
"alternatives": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not proposals:
|
||||||
|
groups.append({**base, "decision": "REVIEW", "reason": "no_replacement", "alternatives": []}); continue
|
||||||
|
|
||||||
|
alternatives = []
|
||||||
|
for proposed in proposals:
|
||||||
|
pair_key = (
|
||||||
|
original.strip().casefold(),
|
||||||
|
proposed.strip().casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
remembered_decision = None
|
||||||
|
|
||||||
|
if pair_key in approved_pairs:
|
||||||
|
remembered_decision = (
|
||||||
|
"APPROVED_PREVIOUSLY"
|
||||||
|
)
|
||||||
|
elif pair_key in rejected_pairs:
|
||||||
|
remembered_decision = (
|
||||||
|
"REJECTED_PREVIOUSLY"
|
||||||
|
)
|
||||||
|
|
||||||
|
filters = candidate_filters(
|
||||||
|
original,
|
||||||
|
proposed,
|
||||||
|
paragraph,
|
||||||
|
offset,
|
||||||
|
rule,
|
||||||
|
)
|
||||||
|
hard = sorted(set(filters) & HARD_FILTERS)
|
||||||
|
soft = sorted(set(filters) & SOFT_FILTERS)
|
||||||
|
item = {
|
||||||
|
"proposed": proposed,
|
||||||
|
"filters": filters,
|
||||||
|
"hard_filters": hard,
|
||||||
|
"soft_filters": soft,
|
||||||
|
"hunspell_valid":
|
||||||
|
hunspell_valid(proposed),
|
||||||
|
"remembered_decision":
|
||||||
|
remembered_decision,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
remembered_decision
|
||||||
|
== "REJECTED_PREVIOUSLY"
|
||||||
|
):
|
||||||
|
item.update({
|
||||||
|
"scored": False,
|
||||||
|
"reason":
|
||||||
|
"rejected_in_previous_review",
|
||||||
|
})
|
||||||
|
alternatives.append(item)
|
||||||
|
continue
|
||||||
|
if hard:
|
||||||
|
item.update({"scored": False, "reason": "hard_filtered"}); alternatives.append(item); continue
|
||||||
|
(
|
||||||
|
original_context,
|
||||||
|
candidate_context,
|
||||||
|
) = local_context(
|
||||||
|
paragraph=paragraph,
|
||||||
|
offset=offset,
|
||||||
|
length=length,
|
||||||
|
proposed=proposed,
|
||||||
|
)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
y = npu.inference(
|
||||||
|
inputs=inputs_for(
|
||||||
|
tok,
|
||||||
|
original_context,
|
||||||
|
candidate_context,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
times.append(
|
||||||
|
time.perf_counter() - start
|
||||||
|
)
|
||||||
|
logits = np.asarray(y[0], np.float32).reshape(-1); probs = softmax(logits)
|
||||||
|
item.update({"scored": True, "npu_original_context": original_context,
|
||||||
|
"candidate_context": candidate_context, "probability_original": float(probs[0]), "probability_proposal": float(probs[1]), "logits": logits.tolist()})
|
||||||
|
alternatives.append(item)
|
||||||
|
|
||||||
|
scored = sorted((x for x in alternatives if x.get("scored")), key=lambda x: x["probability_proposal"], reverse=True)
|
||||||
|
original_valid = hunspell_valid(original)
|
||||||
|
if not scored:
|
||||||
|
decision, reason, best, second, margin = "KEEP_FILTERED", "all_alternatives_hard_filtered", None, None, None
|
||||||
|
else:
|
||||||
|
best = scored[0]; second = scored[1] if len(scored) > 1 else None
|
||||||
|
margin = best["probability_proposal"] - (second["probability_proposal"] if second else 0.0)
|
||||||
|
safe = (best["probability_proposal"] >= a.threshold and margin >= a.min_margin and not best["filters"] and best["hunspell_valid"] is not False and original_valid is not True)
|
||||||
|
if safe:
|
||||||
|
decision, reason = (
|
||||||
|
"AUTO_APPLY",
|
||||||
|
"best_clear_and_above_threshold",
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
best["probability_proposal"] >= 0.990
|
||||||
|
and margin >= a.min_margin
|
||||||
|
):
|
||||||
|
decision, reason = (
|
||||||
|
"REVIEW_HIGH",
|
||||||
|
"strong_proposal_below_auto_threshold",
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
best["probability_proposal"]
|
||||||
|
>= a.review_threshold
|
||||||
|
):
|
||||||
|
decision, reason = (
|
||||||
|
"REVIEW_AMBIGUOUS",
|
||||||
|
(
|
||||||
|
"ambiguous_alternatives"
|
||||||
|
if margin < a.min_margin
|
||||||
|
else "moderate_proposal"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
decision, reason = (
|
||||||
|
"KEEP",
|
||||||
|
"original_preferred_or_low_confidence",
|
||||||
|
)
|
||||||
|
|
||||||
|
groups.append({**base, "decision": decision, "reason": reason, "original_hunspell_valid": original_valid, "best_proposal": best["proposed"] if best else None, "best_probability": best["probability_proposal"] if best else None, "second_proposal": second["proposed"] if second else None, "second_probability": second["probability_proposal"] if second else None, "margin": margin, "alternatives": alternatives})
|
||||||
|
|
||||||
|
if paragraph_count % 100 == 0: print(f"Paragrafi: {paragraph_count} | Errori raggruppati: {len(groups)}", flush=True)
|
||||||
|
finally: npu.release()
|
||||||
|
|
||||||
|
summary = dict(Counter(x.get("decision", "UNKNOWN") for x in groups))
|
||||||
|
report = {"mode": "dry-run-grouped", "source": str(a.epub), "model": str(a.model), "threshold": a.threshold, "min_margin": a.min_margin, "review_threshold": a.review_threshold, "paragraphs": paragraph_count, "summary": summary, "mean_npu_ms": 1000*sum(times)/len(times) if times else None, "groups": groups}
|
||||||
|
out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print("\n=== RISULTATO DRY-RUN RAGGRUPPATO ==="); print("Paragrafi:", paragraph_count); print("Errori LanguageTool raggruppati:", len(groups))
|
||||||
|
for k,v in sorted(summary.items()): print(f"{k}: {v}")
|
||||||
|
print("Inferenze NPU:", len(times)); print("Latenza media NPU:", f"{report['mean_npu_ms']:.2f} ms" if times else "n/a"); print("Rapporto:", out); print("EPUB modificato: NO")
|
||||||
|
|
||||||
|
if __name__ == "__main__": main()
|
||||||
58
generate_review_html.py
Normal file
58
generate_review_html.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse, html, json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TEMPLATE = r'''<!doctype html>
|
||||||
|
<html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Revisione correzioni EPUB</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f5f7fb;--panel:#fff;--text:#172033;--muted:#667085;--line:#d9dee8;--blue:#175cd3;--green:#067647;--red:#b42318;--amber:#b54708}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}
|
||||||
|
header{position:sticky;top:0;z-index:4;background:#101828;color:white;padding:14px 20px;box-shadow:0 2px 12px #0003}
|
||||||
|
header h1{font-size:20px;margin:0 0 8px}.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.toolbar button,.toolbar select,.toolbar input{border:1px solid #98a2b3;border-radius:7px;padding:8px 10px;background:white;color:#101828}.toolbar button{cursor:pointer;font-weight:650}.toolbar .primary{background:#1570ef;color:white;border-color:#1570ef}.stats{margin-left:auto;font-weight:650}
|
||||||
|
main{max-width:1200px;margin:18px auto;padding:0 14px 80px}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;margin:12px 0;overflow:hidden;box-shadow:0 1px 4px #1018280d}.card-head{display:grid;grid-template-columns:1fr auto;gap:10px;padding:13px 15px;border-bottom:1px solid var(--line)}.title{font-weight:750}.meta{color:var(--muted);font-size:13px}.prob{font:700 14px ui-monospace,monospace}.card-body{padding:15px}.change{font-size:18px;margin-bottom:10px}.old{color:var(--red);text-decoration:line-through}.new{color:var(--green);font-weight:750}.context{background:#f8fafc;border:1px solid #e4e7ec;border-radius:8px;padding:12px;white-space:pre-wrap}.mark-old{background:#fee4e2;color:#912018;padding:1px 3px;border-radius:3px}.mark-new{background:#d1fadf;color:#05603a;padding:1px 3px;border-radius:3px}.alts{margin-top:10px;color:var(--muted);font-size:13px}.actions{display:flex;gap:8px;padding:12px 15px;background:#fcfcfd;border-top:1px solid var(--line)}.actions button{border:1px solid var(--line);border-radius:7px;padding:8px 12px;background:white;cursor:pointer;font-weight:700}.approve.active{background:#d1fadf;border-color:#12b76a;color:#05603a}.reject.active{background:#fee4e2;border-color:#f04438;color:#912018}.skip.active{background:#fef0c7;border-color:#f79009;color:#93370d}.badge{display:inline-block;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:700;background:#eef4ff;color:#3538cd}.hidden{display:none!important}.empty{text-align:center;color:var(--muted);padding:50px}.note,.rewrite{width:100%;margin-top:10px;border:1px solid var(--line);border-radius:7px;padding:8px}.rewrite{min-height:92px;font-family:inherit}.modal{position:fixed;inset:0;background:#101828aa;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px}.modal-box{background:white;border-radius:12px;max-width:560px;width:100%;padding:20px}.modal-row{display:flex;gap:8px;margin-top:12px}.modal-row input{flex:1;padding:10px}
|
||||||
|
</style></head><body>
|
||||||
|
<header><h1>Revisione correzioni EPUB</h1><div class="toolbar">
|
||||||
|
<select id="decision"><option value="REVIEW_HIGH">REVIEW_HIGH</option><option value="AUTO_APPLY">AUTO_APPLY</option><option value="REVIEW_AMBIGUOUS">REVIEW_AMBIGUOUS</option><option value="ALL">Tutte</option></select>
|
||||||
|
<select id="status"><option value="pending">Da decidere</option><option value="all">Tutti gli stati</option><option value="approved">Approvati</option><option value="rejected">Rifiutati</option><option value="skipped">Rimandati</option></select>
|
||||||
|
<input id="search" placeholder="Cerca parola o contesto">
|
||||||
|
<button id="prev">← Precedente</button><button id="next">Successivo →</button>
|
||||||
|
<button id="addWord">+ Aggiungi nuova parola</button><button id="export" class="primary">Esporta approvazioni JSON</button><button id="save">Salva sessione</button><button id="reset">Azzera</button>
|
||||||
|
<span class="stats" id="stats"></span></div></header>
|
||||||
|
<main><div id="cards"></div><div id="empty" class="empty hidden">Nessun caso con questi filtri.</div></main><div id="wordModal" class="modal hidden"><div class="modal-box"><h2>Aggiungi nuova parola</h2><p>La parola o locuzione verrà conservata nei prossimi controlli.</p><div class="modal-row"><input id="newWord" placeholder="es. devushka o ensalada rusa"><button id="confirmWord">Aggiungi</button><button id="cancelWord">Annulla</button></div><input id="wordNote" class="note" placeholder="Nota facoltativa"></div></div>
|
||||||
|
<script id="payload" type="application/json">__PAYLOAD__</script>
|
||||||
|
<script>
|
||||||
|
const payload=JSON.parse(document.getElementById('payload').textContent);const items=payload.items;const KEY='epub-review:'+payload.source;
|
||||||
|
let state=JSON.parse(localStorage.getItem(KEY)||'{}');let visible=[];
|
||||||
|
const $=id=>document.getElementById(id);const esc=s=>(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
function mark(text,word,cls){const safe=esc(text),w=esc(word);const i=safe.toLocaleLowerCase('it').indexOf(w.toLocaleLowerCase('it'));return i<0?safe:safe.slice(0,i)+`<span class="${cls}">`+safe.slice(i,i+w.length)+'</span>'+safe.slice(i+w.length)}
|
||||||
|
function statusOf(id){return state[id]?.status||'pending'}
|
||||||
|
function render(){const d=$('decision').value,s=$('status').value,q=$('search').value.trim().toLocaleLowerCase('it');visible=items.filter(x=>(d==='ALL'||x.decision===d)&&(s==='all'||statusOf(x.id)===s)&&(!q||JSON.stringify(x).toLocaleLowerCase('it').includes(q)));$('cards').innerHTML=visible.map(card).join('');$('empty').classList.toggle('hidden',visible.length>0);stats()}
|
||||||
|
function card(x){const st=statusOf(x.id),ctxOld=mark(x.context,x.original,'mark-old');const ctxNew=mark(x.context,x.original,'mark-new').replace(esc(x.original),esc(x.proposal));const alts=(x.alternatives||[]).slice(0,5).map(a=>`${esc(a.proposed)} (${(a.probability_proposal??0).toFixed(6)})`).join(' · ');return `<article class="card" id="${x.id}"><div class="card-head"><div><div class="title"><span class="badge">${esc(x.decision)}</span> ${esc(x.rule)}</div><div class="meta">${esc(x.file)} · paragrafo ${x.paragraph} · offset ${x.offset}</div></div><div class="prob">P=${x.probability.toFixed(6)}<br>margine=${(x.margin??0).toFixed(6)}</div></div><div class="card-body"><div class="change"><span class="old">${esc(x.original)}</span> → <span class="new">${esc(x.proposal)}</span></div><div class="context">${ctxOld}</div><div class="alts">Alternative: ${alts||'nessuna'}</div><textarea class="rewrite" data-rewrite="${x.id}" placeholder="Riscrivi qui l'intera frase o paragrafo.">${esc(state[x.id]?.rewrite||'')}</textarea><input class="note" data-note="${x.id}" value="${esc(state[x.id]?.note||'')}" placeholder="Nota facoltativa"></div><div class="actions"><button class="approve ${st==='approved'?'active':''}" onclick="setStatus('${x.id}','approved')">Approva proposta</button><button class="approve ${st==='rewritten'?'active':''}" onclick="setStatus('${x.id}','rewritten')">Salva riscrittura</button><button onclick="preserveFromCase('${x.id}')">Mantieni e ricorda</button><button class="reject ${st==='rejected'?'active':''}" onclick="setStatus('${x.id}','rejected')">Rifiuta</button><button class="skip ${st==='skipped'?'active':''}" onclick="setStatus('${x.id}','skipped')">Rimanda</button></div></article>`}
|
||||||
|
window.setStatus=(id,status)=>{const note=document.querySelector(`[data-note="${id}"]`)?.value||state[id]?.note||'';const rewrite=document.querySelector(`[data-rewrite="${id}"]`)?.value||state[id]?.rewrite||'';if(status==='rewritten'&&!rewrite.trim()){alert('Inserisci la frase riscritta.');return}state[id]={status,note,rewrite};localStorage.setItem(KEY,JSON.stringify(state));render()};
|
||||||
|
function stats(){const c={pending:0,approved:0,rewritten:0,preserved:0,rejected:0,skipped:0};items.forEach(x=>c[statusOf(x.id)]++);$('stats').textContent=`Visibili ${visible.length}/${items.length} · ✓ ${c.approved} · riscritte ${c.rewritten} · diz. ${c.preserved} · ✕ ${c.rejected} · ? ${c.skipped} · da fare ${c.pending}`}
|
||||||
|
function move(dir){const cards=[...document.querySelectorAll('.card')];if(!cards.length)return;const y=scrollY+120;let idx=cards.findIndex(c=>c.offsetTop>y);if(dir<0)idx=Math.max(0,(idx<0?cards.length:idx)-1);else idx=Math.min(cards.length-1,idx<0?cards.length-1:idx);cards[idx].scrollIntoView({behavior:'smooth',block:'start'})}
|
||||||
|
$('prev').onclick=()=>move(-1);$('next').onclick=()=>move(1);['decision','status'].forEach(id=>$(id).onchange=render);$('search').oninput=render;
|
||||||
|
$('save').onclick=()=>{document.querySelectorAll('[data-note]').forEach(n=>{const id=n.dataset.note;if(state[id])state[id].note=n.value});localStorage.setItem(KEY,JSON.stringify(state));alert('Sessione salvata nel browser.')};
|
||||||
|
$('reset').onclick=()=>{if(confirm('Azzera tutte le decisioni?')){state={};localStorage.removeItem(KEY);render()}};
|
||||||
|
let preserveWords=JSON.parse(localStorage.getItem(KEY+':preserve')||'[]');
|
||||||
|
function addPreserveWord(word,note='',source='manual'){word=(word||'').trim();if(!word)return false;const key=word.toLocaleLowerCase('it');if(!preserveWords.some(x=>x.value.toLocaleLowerCase('it')===key))preserveWords.push({value:word,note,source,created_at:new Date().toISOString()});localStorage.setItem(KEY+':preserve',JSON.stringify(preserveWords));return true}
|
||||||
|
window.preserveFromCase=id=>{const x=items.find(v=>v.id===id);if(!x)return;addPreserveWord(x.original,state[id]?.note||'',`case:${id}`);state[id]={...(state[id]||{}),status:'preserved'};localStorage.setItem(KEY,JSON.stringify(state));render()};
|
||||||
|
$('addWord').onclick=()=>{$('newWord').value='';$('wordNote').value='';$('wordModal').classList.remove('hidden');$('newWord').focus()};$('cancelWord').onclick=()=>$('wordModal').classList.add('hidden');$('confirmWord').onclick=()=>{if(addPreserveWord($('newWord').value,$('wordNote').value)){$('wordModal').classList.add('hidden');alert('Parola aggiunta.')}else alert('Inserisci una parola.')};
|
||||||
|
$('export').onclick=()=>{document.querySelectorAll('[data-note]').forEach(n=>{const id=n.dataset.note;if(state[id])state[id].note=n.value});const approved=items.filter(x=>statusOf(x.id)==='approved').map(x=>({file:x.file,paragraph:x.paragraph,offset:x.offset,original:x.original,replacement:x.proposal,probability:x.probability,margin:x.margin,reason:'Approvata nella pagina di revisione',note:state[x.id]?.note||''}));const sentence_rewrites=items.filter(x=>statusOf(x.id)==='rewritten').map(x=>({file:x.file,paragraph:x.paragraph,original_context:x.context,rewritten_context:(state[x.id]?.rewrite||'').trim(),trigger_original:x.original,trigger_proposal:x.proposal,note:state[x.id]?.note||''}));const rejected=items.filter(x=>statusOf(x.id)==='rejected').map(x=>({id:x.id,original:x.original,replacement:x.proposal,note:state[x.id]?.note||''}));const out={source_report:payload.source,created_at:new Date().toISOString(),approved,sentence_rewrites,preserved:preserveWords,rejected};const blob=new Blob([JSON.stringify(out,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='approved_changes.json';a.click();URL.revokeObjectURL(a.href)};
|
||||||
|
render();
|
||||||
|
</script></body></html>'''
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('report',type=Path);p.add_argument('--output',type=Path,default=Path('reports/review.html'));a=p.parse_args()
|
||||||
|
data=json.loads(a.report.read_text(encoding='utf-8')); selected=[]
|
||||||
|
for i,x in enumerate(data.get('groups',[])):
|
||||||
|
if x.get('decision') not in {'AUTO_APPLY','REVIEW_HIGH','REVIEW_AMBIGUOUS'}: continue
|
||||||
|
best=x.get('best_proposal'); prob=x.get('best_probability')
|
||||||
|
if not best or prob is None: continue
|
||||||
|
selected.append({'id':f'c{i:05d}','decision':x.get('decision'),'rule':x.get('rule',''),'file':x.get('file',''),'paragraph':x.get('paragraph',0),'offset':x.get('offset',0),'original':x.get('original',''),'proposal':best,'probability':float(prob),'margin':x.get('margin'),'context':x.get('context',''),'alternatives':[z for z in x.get('alternatives',[]) if z.get('scored')]})
|
||||||
|
selected.sort(key=lambda x:(0 if x['decision']=='AUTO_APPLY' else 1 if x['decision']=='REVIEW_HIGH' else 2,-x['probability']))
|
||||||
|
payload=json.dumps({'source':str(a.report),'items':selected},ensure_ascii=False).replace('</','<\\/')
|
||||||
|
a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(TEMPLATE.replace('__PAYLOAD__',payload),encoding='utf-8')
|
||||||
|
print('HTML:',a.output);print('Casi:',len(selected));print('AUTO_APPLY:',sum(x['decision']=='AUTO_APPLY' for x in selected));print('REVIEW_HIGH:',sum(x['decision']=='REVIEW_HIGH' for x in selected));print('REVIEW_AMBIGUOUS:',sum(x['decision']=='REVIEW_AMBIGUOUS' for x in selected))
|
||||||
|
if __name__=='__main__':main()
|
||||||
562
guida_orange_pi_epub_npu.md
Normal file
562
guida_orange_pi_epub_npu.md
Normal file
|
|
@ -0,0 +1,562 @@
|
||||||
|
# Correttore EPUB italiano su Orange Pi 5 Plus con NPU
|
||||||
|
|
||||||
|
## Guida per ricreare l'esperimento su un altro Orange Pi
|
||||||
|
|
||||||
|
Questa guida documenta la parte dell'esperimento che ha dato i risultati più promettenti: usare un modello **BERT italiano specializzato nella valutazione di coppie di frasi**, con l'obiettivo finale di convertirlo da PyTorch a ONNX e poi a RKNN per eseguirlo sulla NPU del Rockchip RK3588.
|
||||||
|
|
||||||
|
L'approccio non chiede al modello di riscrivere liberamente il libro. Il modello confronta due versioni della stessa frase e assegna un punteggio alla versione linguisticamente migliore. Questo riduce drasticamente le correzioni inventate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Hardware e sistema testati
|
||||||
|
|
||||||
|
Configurazione usata:
|
||||||
|
|
||||||
|
- Orange Pi 5 Plus
|
||||||
|
- SoC Rockchip RK3588
|
||||||
|
- NPU RK3588 attiva a 1 GHz
|
||||||
|
- driver `rknpu 0.9.8`
|
||||||
|
- 16 GB di RAM, circa 15 GiB visibili
|
||||||
|
- Armbian su Debian 13 Trixie
|
||||||
|
- kernel `6.1.115-vendor-rk35xx`
|
||||||
|
- architettura `aarch64`
|
||||||
|
- NVMe montato in `/home/nvme`
|
||||||
|
|
||||||
|
Verifica iniziale:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uname -m
|
||||||
|
uname -r
|
||||||
|
free -h
|
||||||
|
modinfo rknpu 2>/dev/null | grep -E '^(version|filename):'
|
||||||
|
cat /sys/class/devfreq/fdab0000.npu/cur_freq
|
||||||
|
```
|
||||||
|
|
||||||
|
Risultati attesi indicativi:
|
||||||
|
|
||||||
|
```text
|
||||||
|
aarch64
|
||||||
|
6.1.x-vendor-rk35xx
|
||||||
|
version: 0.9.8
|
||||||
|
1000000000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Perché abbiamo cambiato strada
|
||||||
|
|
||||||
|
Sono stati provati tre modelli generativi RKLLM sulla NPU:
|
||||||
|
|
||||||
|
| Modello | Test ortografico vincolato |
|
||||||
|
|---|---:|
|
||||||
|
| Qwen2.5 1.5B Instruct | 0/5 |
|
||||||
|
| Qwen3 4B Instruct 2507 | 4/5 |
|
||||||
|
| Gemma 3 4B IT | 2/5 |
|
||||||
|
|
||||||
|
Qwen3 4B era il migliore, ma mostrava un bias verso la posizione delle alternative e, in generazione libera, poteva cambiare il significato. Per esempio:
|
||||||
|
|
||||||
|
```text
|
||||||
|
coliere -> coltivare
|
||||||
|
```
|
||||||
|
|
||||||
|
invece di:
|
||||||
|
|
||||||
|
```text
|
||||||
|
coliere -> cogliere
|
||||||
|
```
|
||||||
|
|
||||||
|
La conclusione è stata:
|
||||||
|
|
||||||
|
- usare LanguageTool e Hunspell per rilevare o generare candidati;
|
||||||
|
- non usare un LLM generativo come decisore unico;
|
||||||
|
- usare un encoder italiano specializzato per confrontare due frasi;
|
||||||
|
- applicare automaticamente solo correzioni con margine elevato;
|
||||||
|
- inviare i casi incerti alla revisione manuale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ambiente dell'esperimento
|
||||||
|
|
||||||
|
Creare il progetto su NVMe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /home/nvme/epub-gec-experiment
|
||||||
|
cd /home/nvme/epub-gec-experiment
|
||||||
|
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install \
|
||||||
|
torch \
|
||||||
|
transformers \
|
||||||
|
datasets \
|
||||||
|
scikit-learn \
|
||||||
|
pandas \
|
||||||
|
onnx \
|
||||||
|
onnxruntime \
|
||||||
|
optimum \
|
||||||
|
beautifulsoup4 \
|
||||||
|
lxml \
|
||||||
|
accelerate \
|
||||||
|
safetensors
|
||||||
|
```
|
||||||
|
|
||||||
|
Controllo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
import torch
|
||||||
|
import transformers
|
||||||
|
import onnxruntime
|
||||||
|
|
||||||
|
print('Torch:', torch.__version__)
|
||||||
|
print('Transformers:', transformers.__version__)
|
||||||
|
print('ONNX Runtime:', onnxruntime.__version__)
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modello base scelto
|
||||||
|
|
||||||
|
Modello utilizzato:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dbmdz/bert-base-italian-cased
|
||||||
|
```
|
||||||
|
|
||||||
|
È stato usato inizialmente come `AutoModelForMaskedLM` per verificare se possedeva già un buon segnale linguistico italiano.
|
||||||
|
|
||||||
|
Il principio della baseline è la **pseudo-log-likelihood**:
|
||||||
|
|
||||||
|
1. si maschera un token alla volta;
|
||||||
|
2. BERT calcola la probabilità del token originale;
|
||||||
|
3. si sommano o mediano i logaritmi delle probabilità;
|
||||||
|
4. tra due frasi viene preferita quella col punteggio più alto.
|
||||||
|
|
||||||
|
Questa tecnica è lenta, perché richiede circa un'inferenza per token, ma è molto utile come prova di fattibilità.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dataset diagnostico iniziale
|
||||||
|
|
||||||
|
Abbiamo creato 14 coppie manuali. Ogni coppia conteneva una frase corretta e una errata.
|
||||||
|
|
||||||
|
Esempi:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Le piaceva cogliere i fiori e metterli nei vasi.
|
||||||
|
Le piaceva coliere i fiori e metterli nei vasi.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
La notte che cambiò la mia vita.
|
||||||
|
La notte che cambio la mia vita.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Entrò lentamente nella stanza.
|
||||||
|
Entrò lentamente nellastanza.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Quel merdoso mi ha mentito.
|
||||||
|
Quel mendoso mi ha mentito.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Il monsieur ci accolse alla porta.
|
||||||
|
Il monsiu ci accolse alla porta.
|
||||||
|
```
|
||||||
|
|
||||||
|
Risultato della baseline BERT:
|
||||||
|
|
||||||
|
```text
|
||||||
|
13/14 = 92,9%
|
||||||
|
```
|
||||||
|
|
||||||
|
L'unico errore è stato:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scoreggiare
|
||||||
|
```
|
||||||
|
|
||||||
|
Il modello ha preferito `scoraggiare`, probabilmente perché è molto più frequente nei corpora formali.
|
||||||
|
|
||||||
|
Questo risultato è stato considerato sufficientemente promettente per procedere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Interpretazione dei margini
|
||||||
|
|
||||||
|
Il punteggio più alto indica la frase preferita. Il margine fra frase corretta e alternativa è importante.
|
||||||
|
|
||||||
|
Esempi osservati:
|
||||||
|
|
||||||
|
| Coppia | Margine approssimativo |
|
||||||
|
|---|---:|
|
||||||
|
| `cogliere` vs `coliere` | 0,79 |
|
||||||
|
| `assassinò` vs `assasinò` | 1,59 |
|
||||||
|
| `accuratezza` vs `acuratezza` | 2,01 |
|
||||||
|
| `fomentato` vs `formentato` | 2,65 |
|
||||||
|
| `cambiò` vs `cambio` | 0,34 |
|
||||||
|
| `finì` vs `fini` | 1,62 |
|
||||||
|
| `nella stanza` vs `nellastanza` | 3,88 |
|
||||||
|
| `straordinario` vs `stra ordinario` | 2,85 |
|
||||||
|
| `merdoso` vs `mendoso` | 0,12 |
|
||||||
|
|
||||||
|
Una prima soglia conservativa possibile è:
|
||||||
|
|
||||||
|
```text
|
||||||
|
margine >= 0,75
|
||||||
|
```
|
||||||
|
|
||||||
|
I casi sotto soglia devono andare in revisione.
|
||||||
|
|
||||||
|
La soglia definitiva dovrà essere calibrata su un test manuale più grande, puntando a una precisione delle correzioni automatiche di almeno il 99%.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Preparazione del dataset sintetico
|
||||||
|
|
||||||
|
È stato generato un dataset da un EPUB già parzialmente corretto:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/epub-corrector/output/a_corretto_ortografia.epub
|
||||||
|
```
|
||||||
|
|
||||||
|
Categorie sintetiche:
|
||||||
|
|
||||||
|
- `accent`: rimozione di accenti;
|
||||||
|
- `double`: rimozione di una consonante doppia;
|
||||||
|
- `fuse`: fusione di due parole;
|
||||||
|
- `split`: divisione di una parola;
|
||||||
|
- `punct_space`: rimozione dello spazio dopo punteggiatura.
|
||||||
|
|
||||||
|
Il generatore deve creare coppie bilanciate:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text_a": "La notte che cambio la mia vita.",
|
||||||
|
"text_b": "La notte che cambiò la mia vita.",
|
||||||
|
"label": 1,
|
||||||
|
"error_type": "accent"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La `label` significa:
|
||||||
|
|
||||||
|
- `1`: la frase B è corretta;
|
||||||
|
- `0`: la frase A è corretta.
|
||||||
|
|
||||||
|
L'ordine deve essere invertito casualmente per evitare bias di posizione.
|
||||||
|
|
||||||
|
Comando usato:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/nvme/epub-gec-experiment
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
python3 make_training_pairs.py \
|
||||||
|
~/epub-corrector/output/a_corretto_ortografia.epub \
|
||||||
|
--per-category 4000 \
|
||||||
|
--output pairs_balanced.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Distribuzione ottenuta:
|
||||||
|
|
||||||
|
```text
|
||||||
|
accent 4000
|
||||||
|
double 4000
|
||||||
|
fuse 4000
|
||||||
|
split 4000
|
||||||
|
punct_space 581
|
||||||
|
Totale 16581
|
||||||
|
```
|
||||||
|
|
||||||
|
Label:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0: 8240
|
||||||
|
1: 8341
|
||||||
|
```
|
||||||
|
|
||||||
|
Il dataset è quindi quasi perfettamente bilanciato per posizione.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Controllo del dataset
|
||||||
|
|
||||||
|
Comando:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 check_dataset.py pairs_balanced.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Output ottenuto:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Righe: 16581
|
||||||
|
Label: {0: 8240, 1: 8341}
|
||||||
|
Tipi: {'split': 4000, 'accent': 4000, 'double': 4000, 'fuse': 4000, 'punct_space': 581}
|
||||||
|
Controlli: OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Controlli indispensabili:
|
||||||
|
|
||||||
|
- `text_a` deve essere diverso da `text_b`;
|
||||||
|
- devono esistere entrambe le label;
|
||||||
|
- nessuna categoria deve dominare eccessivamente;
|
||||||
|
- la versione dichiarata corretta deve essere realmente corretta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Split senza leakage
|
||||||
|
|
||||||
|
Tutte le varianti derivate dalla stessa frase devono finire nello stesso split.
|
||||||
|
|
||||||
|
Per questo ogni frase sorgente riceve un `group_id` e lo split avviene per gruppo, non per singola riga.
|
||||||
|
|
||||||
|
Comando:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 split_dataset.py \
|
||||||
|
pairs_balanced.jsonl \
|
||||||
|
--outdir data_balanced
|
||||||
|
```
|
||||||
|
|
||||||
|
Risultato:
|
||||||
|
|
||||||
|
```text
|
||||||
|
train gruppi=7906 righe=13285
|
||||||
|
validation gruppi= 988 righe= 1640
|
||||||
|
test gruppi= 989 righe= 1656
|
||||||
|
Leakage tra split: assente
|
||||||
|
```
|
||||||
|
|
||||||
|
Questa parte è fondamentale. Se versioni quasi identiche della stessa frase finiscono sia nel training sia nel test, le metriche risultano artificialmente alte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Classificatore di coppie
|
||||||
|
|
||||||
|
Architettura prevista:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BertForSequenceClassification
|
||||||
|
```
|
||||||
|
|
||||||
|
Input:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[CLS] frase A [SEP] frase B [SEP]
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 = A è corretta
|
||||||
|
1 = B è corretta
|
||||||
|
```
|
||||||
|
|
||||||
|
Vantaggio rispetto alla pseudo-log-likelihood:
|
||||||
|
|
||||||
|
- una sola inferenza per coppia;
|
||||||
|
- molto più facile da esportare in ONNX;
|
||||||
|
- molto più adatto alla conversione RKNN;
|
||||||
|
- probabilità o logit calibrabili;
|
||||||
|
- nessuna generazione libera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Primo training pilota
|
||||||
|
|
||||||
|
Per ridurre il costo sul RK3588, il training pilota congela quasi tutto BERT e addestra:
|
||||||
|
|
||||||
|
- classificatore;
|
||||||
|
- pooler;
|
||||||
|
- ultimo layer BERT.
|
||||||
|
|
||||||
|
Comando consigliato:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/nvme/epub-gec-experiment
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
export OMP_NUM_THREADS=6
|
||||||
|
export MKL_NUM_THREADS=6
|
||||||
|
|
||||||
|
python3 -u train_pair_classifier.py \
|
||||||
|
--data data_balanced \
|
||||||
|
--output models/bert-pair-pilot \
|
||||||
|
--epochs 1 \
|
||||||
|
--batch-size 4 \
|
||||||
|
--gradient-accumulation 4 \
|
||||||
|
--max-length 128 \
|
||||||
|
--train-last-layers 1 \
|
||||||
|
--workers 2 \
|
||||||
|
2>&1 | tee train_pilot.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Parametri effettivi:
|
||||||
|
|
||||||
|
- batch fisico: 4;
|
||||||
|
- accumulo gradienti: 4;
|
||||||
|
- batch effettivo: 16;
|
||||||
|
- lunghezza massima: 128 token;
|
||||||
|
- un'epoca;
|
||||||
|
- ultimo layer BERT addestrabile.
|
||||||
|
|
||||||
|
Tempo stimato sul solo RK3588 CPU:
|
||||||
|
|
||||||
|
```text
|
||||||
|
circa 2-6 ore
|
||||||
|
```
|
||||||
|
|
||||||
|
La velocità reale appare dopo i primi 100 batch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Monitoraggio durante il training
|
||||||
|
|
||||||
|
In un secondo terminale:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
watch -n 3 '
|
||||||
|
free -h
|
||||||
|
echo
|
||||||
|
ps -C python3 -o pid,rss,%mem,%cpu,etime,cmd --sort=-%cpu | head -n 8
|
||||||
|
echo
|
||||||
|
cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null | awk "{printf \"Temp: %.1f C\\n\", \$1/1000}"
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Se la temperatura resta sopra 80 °C:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OMP_NUM_THREADS=4
|
||||||
|
```
|
||||||
|
|
||||||
|
poi riavviare il training.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Criteri per promuovere il pilot
|
||||||
|
|
||||||
|
Sul validation set:
|
||||||
|
|
||||||
|
```text
|
||||||
|
F1 >= 0,95
|
||||||
|
```
|
||||||
|
|
||||||
|
Procedere al training completo.
|
||||||
|
|
||||||
|
```text
|
||||||
|
F1 tra 0,90 e 0,95
|
||||||
|
```
|
||||||
|
|
||||||
|
Addestrare gli ultimi 2-3 layer per due epoche.
|
||||||
|
|
||||||
|
```text
|
||||||
|
F1 < 0,90
|
||||||
|
```
|
||||||
|
|
||||||
|
Rivedere dataset e architettura.
|
||||||
|
|
||||||
|
Per il correttore automatico la metrica più importante non è l'accuracy generale, ma la precisione delle modifiche applicate.
|
||||||
|
|
||||||
|
Target finale:
|
||||||
|
|
||||||
|
```text
|
||||||
|
precisione automatica >= 99%
|
||||||
|
```
|
||||||
|
|
||||||
|
È accettabile lasciare molti casi in revisione. Non è accettabile introdurre errori nuovi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Test finale prima della NPU
|
||||||
|
|
||||||
|
Prima della conversione servono tre valutazioni:
|
||||||
|
|
||||||
|
1. test sintetico separato `data_balanced/test.jsonl`;
|
||||||
|
2. test diagnostico manuale con errori reali;
|
||||||
|
3. test negativo con parole rare, colloquiali, straniere e nomi propri.
|
||||||
|
|
||||||
|
Categorie negative importanti:
|
||||||
|
|
||||||
|
- `merdoso` non deve diventare `mendoso`;
|
||||||
|
- `scoreggiare` non deve diventare `scoraggiare` se il contesto indica flatulenza;
|
||||||
|
- `monsieur` non deve essere italianizzato;
|
||||||
|
- `blackjack` non deve diventare un nome proprio simile;
|
||||||
|
- nomi come `Lisbon`, `Dallas`, `Oswald` devono restare invariati.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Percorso previsto verso la NPU
|
||||||
|
|
||||||
|
Dopo il superamento dei test:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PyTorch FP32
|
||||||
|
-> ONNX FP32
|
||||||
|
-> RKNN INT8 / W8A8
|
||||||
|
-> RKNN Toolkit Lite2 sulla NPU RK3588
|
||||||
|
```
|
||||||
|
|
||||||
|
La conversione deve essere fatta in un ambiente separato con Python 3.10-3.12, perché gli strumenti Rockchip e i convertitori Transformer possono non supportare Python 3.13.
|
||||||
|
|
||||||
|
Passaggi futuri:
|
||||||
|
|
||||||
|
1. esportare il classificatore con input fissi da 128 token;
|
||||||
|
2. verificare ONNX con ONNX Runtime;
|
||||||
|
3. confrontare logits PyTorch e ONNX;
|
||||||
|
4. creare un dataset di calibrazione INT8;
|
||||||
|
5. convertire ONNX in `.rknn`;
|
||||||
|
6. eseguire sulla NPU;
|
||||||
|
7. confrontare accuratezza e logits fra FP32 e INT8;
|
||||||
|
8. accettare la quantizzazione solo con perdita minima.
|
||||||
|
|
||||||
|
Criterio suggerito:
|
||||||
|
|
||||||
|
```text
|
||||||
|
precisione FP32 >= 99%
|
||||||
|
precisione RKNN INT8 >= 99% oppure perdita < 0,3 punti percentuali
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Pipeline finale prevista per gli EPUB
|
||||||
|
|
||||||
|
```text
|
||||||
|
EPUB
|
||||||
|
-> regole strutturali conservative
|
||||||
|
-> LanguageTool e Hunspell generano candidati
|
||||||
|
-> classificatore BERT NPU confronta originale e proposta
|
||||||
|
-> calcolo margine
|
||||||
|
-> margine alto: applica
|
||||||
|
-> margine basso: revisione manuale
|
||||||
|
-> validazione XHTML e ZIP
|
||||||
|
-> nuovo EPUB e rapporto
|
||||||
|
```
|
||||||
|
|
||||||
|
Il classificatore non inventa mai la correzione. Sceglie soltanto fra versioni già costruite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Regola di sicurezza finale
|
||||||
|
|
||||||
|
La filosofia del progetto è:
|
||||||
|
|
||||||
|
> Applicare automaticamente solo ciò che presenta evidenza molto forte. Tutto il resto resta invariato e viene mostrato per la conferma manuale.
|
||||||
|
|
||||||
|
Questo approccio sfrutta realmente la NPU e, rispetto ai modelli generativi provati, offre:
|
||||||
|
|
||||||
|
- maggiore stabilità;
|
||||||
|
- nessun bias da generazione libera;
|
||||||
|
- punteggi numerici confrontabili;
|
||||||
|
- soglie calibrabili;
|
||||||
|
- inferenza molto più veloce;
|
||||||
|
- maggiore facilità di conversione in RKNN;
|
||||||
|
- rischio inferiore di alterare lo stile dell'autore.
|
||||||
9
requirements-rknn.txt
Normal file
9
requirements-rknn.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
Flask==3.1.3
|
||||||
|
lxml==6.1.3
|
||||||
|
numpy==2.4.6
|
||||||
|
requests==2.34.2
|
||||||
|
rknn-toolkit-lite2 @ file:///home/nvme/epub-gec-experiment/rknn-toolkit2-v2.3.2/rknn-toolkit-lite2/packages/rknn_toolkit_lite2-2.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl#sha256=bda74f1179e15fccb8726054a24898982522784b65bb340b20146955d254e800
|
||||||
|
tokenizers==0.22.2
|
||||||
|
|
||||||
|
# RKNNLite 2.3.2 deve essere installato dalla wheel Rockchip
|
||||||
|
# compatibile con Python 3.11, Linux aarch64 e RK3588.
|
||||||
45
rknn/README.md
Normal file
45
rknn/README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Modello RKNN di produzione
|
||||||
|
|
||||||
|
## File
|
||||||
|
|
||||||
|
`bert-pair-v2-eager-fp16.rknn`
|
||||||
|
|
||||||
|
## Compatibilità verificata
|
||||||
|
|
||||||
|
- SoC: Rockchip RK3588
|
||||||
|
- acceleratore: RKNPU v2
|
||||||
|
- RKNN Toolkit2: 2.3.2
|
||||||
|
- RKNN Runtime: 2.3.2
|
||||||
|
- driver NPU: 0.9.8
|
||||||
|
- modello RKNN: versione 6
|
||||||
|
- precisione: FP16
|
||||||
|
- forma statica: 128 token
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
- `input_ids`: INT64 `[1,128]`
|
||||||
|
- `attention_mask`: INT64 `[1,128]`
|
||||||
|
- `token_type_ids`: INT64 `[1,128]`
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
- logits: `[1,2]`
|
||||||
|
- classe 0: preferenza per il testo originale
|
||||||
|
- classe 1: preferenza per la proposta
|
||||||
|
|
||||||
|
## Parametri della pipeline
|
||||||
|
|
||||||
|
- soglia automatica: `0.999`
|
||||||
|
- margine minimo: `0.002`
|
||||||
|
- finestra locale: 120 caratteri per lato
|
||||||
|
- latenza media: circa 84 ms su RK3588
|
||||||
|
|
||||||
|
## Tokenizer
|
||||||
|
|
||||||
|
`assets/tokenizer/tokenizer.json`
|
||||||
|
|
||||||
|
## Verifica integrità
|
||||||
|
|
||||||
|
Eseguire dalla radice del repository:
|
||||||
|
|
||||||
|
sha256sum -c rknn/bert-pair-v2-eager-fp16.sha256
|
||||||
BIN
rknn/bert-pair-v2-eager-fp16.rknn
(Stored with Git LFS)
Normal file
BIN
rknn/bert-pair-v2-eager-fp16.rknn
(Stored with Git LFS)
Normal file
Binary file not shown.
1
rknn/bert-pair-v2-eager-fp16.sha256
Normal file
1
rknn/bert-pair-v2-eager-fp16.sha256
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
5b617eeafa5f0108ad78c47897d2f32e26e6751910a4697678c9d7d931366ac0 rknn/bert-pair-v2-eager-fp16.rknn
|
||||||
Loading…
Reference in a new issue