Add EPUB corrector runtime, web UI, tokenizer and RKNN model

This commit is contained in:
Fabio 2026-10-30 16:08:03 +01:00
commit 6c27e8bddb
19 changed files with 33422 additions and 0 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.rknn filter=lfs diff=lfs merge=lfs -text

159
.gitignore vendored Normal file
View 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
View 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.

View 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()

File diff suppressed because it is too large Load diff

View 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
View 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
}

View 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)

View 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>

View 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`.

View 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
View 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
View 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
View 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
View 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
View 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
View 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

Binary file not shown.

View file

@ -0,0 +1 @@
5b617eeafa5f0108ad78c47897d2f32e26e6751910a4697678c9d7d931366ac0 rknn/bert-pair-v2-eager-fp16.rknn