Commit ·
e658303
1
Parent(s): 01b11d4
revert: restore backend to 1a76471, keep all UI/UX improvements
Browse files- .github/workflows/deploy.yml +0 -22
- hybrid_module.py +0 -147
- src/app.py +0 -80
- trace_output.txt +0 -0
- trace_punc.py +0 -73
- trace_punctuation.py +0 -176
.github/workflows/deploy.yml
CHANGED
|
@@ -7,30 +7,8 @@ on:
|
|
| 7 |
branches: [main]
|
| 8 |
|
| 9 |
jobs:
|
| 10 |
-
test:
|
| 11 |
-
name: Run Regression Tests
|
| 12 |
-
runs-on: ubuntu-latest
|
| 13 |
-
steps:
|
| 14 |
-
- uses: actions/checkout@v4
|
| 15 |
-
|
| 16 |
-
- name: Setup Python
|
| 17 |
-
uses: actions/setup-python@v5
|
| 18 |
-
with:
|
| 19 |
-
python-version: '3.12'
|
| 20 |
-
cache: 'pip'
|
| 21 |
-
|
| 22 |
-
- name: Install dependencies
|
| 23 |
-
run: |
|
| 24 |
-
pip install flask flask-cors python-dotenv pytest
|
| 25 |
-
pip install -r requirements.txt || true
|
| 26 |
-
|
| 27 |
-
- name: Run pipeline tests
|
| 28 |
-
run: |
|
| 29 |
-
cd src && python -m pytest ../tests/test_pipeline_hardening.py ../tests/test_bug_fixes.py -v --tb=short
|
| 30 |
-
|
| 31 |
lint-and-validate:
|
| 32 |
name: Validate Code
|
| 33 |
-
needs: test
|
| 34 |
runs-on: ubuntu-latest
|
| 35 |
steps:
|
| 36 |
- uses: actions/checkout@v4
|
|
|
|
| 7 |
branches: [main]
|
| 8 |
|
| 9 |
jobs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
lint-and-validate:
|
| 11 |
name: Validate Code
|
|
|
|
| 12 |
runs-on: ubuntu-latest
|
| 13 |
steps:
|
| 14 |
- uses: actions/checkout@v4
|
hybrid_module.py
DELETED
|
@@ -1,147 +0,0 @@
|
|
| 1 |
-
|
| 2 |
-
# hybrid_module.py
|
| 3 |
-
|
| 4 |
-
import torch
|
| 5 |
-
import pickle
|
| 6 |
-
from transformers import GPT2LMHeadModel, GPT2Tokenizer
|
| 7 |
-
from huggingface_hub import hf_hub_download
|
| 8 |
-
|
| 9 |
-
# ---------- Load Bigram ----------
|
| 10 |
-
|
| 11 |
-
def load_bigram(repo_id="bayan10/AutoComplete", filename="bigram_model_v4.pkl"):
|
| 12 |
-
path = hf_hub_download(repo_id=repo_id, filename=filename)
|
| 13 |
-
with open(path, "rb") as f:
|
| 14 |
-
data = pickle.load(f)
|
| 15 |
-
return data["unigrams"], data["bigrams"]
|
| 16 |
-
|
| 17 |
-
# ---------- Load GPT-2 ----------
|
| 18 |
-
def load_gpt2(model_name="aubmindlab/aragpt2-base"):
|
| 19 |
-
tokenizer = GPT2Tokenizer.from_pretrained(model_name)
|
| 20 |
-
model = GPT2LMHeadModel.from_pretrained(model_name)
|
| 21 |
-
tokenizer.pad_token = tokenizer.eos_token
|
| 22 |
-
model.config.pad_token_id = tokenizer.eos_token_id
|
| 23 |
-
model.eval()
|
| 24 |
-
return tokenizer, model
|
| 25 |
-
|
| 26 |
-
# ---------- GPT-2 scoring ----------
|
| 27 |
-
def gpt2_next_token_probs(prefix, tokenizer, model, top_k=50):
|
| 28 |
-
inputs = tokenizer(
|
| 29 |
-
prefix,
|
| 30 |
-
return_tensors="pt",
|
| 31 |
-
truncation=True,
|
| 32 |
-
max_length=1024
|
| 33 |
-
)
|
| 34 |
-
|
| 35 |
-
with torch.no_grad():
|
| 36 |
-
outputs = model(**inputs)
|
| 37 |
-
logits = outputs.logits[0, -1]
|
| 38 |
-
|
| 39 |
-
probs = torch.softmax(logits, dim=-1)
|
| 40 |
-
top_probs, top_ids = torch.topk(probs, top_k)
|
| 41 |
-
|
| 42 |
-
prob_dict = {}
|
| 43 |
-
for idx, prob in zip(top_ids, top_probs):
|
| 44 |
-
word = tokenizer.decode([idx]).strip()
|
| 45 |
-
if word:
|
| 46 |
-
prob_dict[word] = prob.item()
|
| 47 |
-
|
| 48 |
-
return prob_dict
|
| 49 |
-
|
| 50 |
-
# ---------- Statistical autocomplete ----------
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
def statistical_autocomplete(text, unigrams, bigrams, top_k=20):
|
| 54 |
-
tokens = text.strip().split()
|
| 55 |
-
if not tokens:
|
| 56 |
-
return []
|
| 57 |
-
|
| 58 |
-
last_word = tokens[-1]
|
| 59 |
-
candidates = []
|
| 60 |
-
|
| 61 |
-
if last_word in bigrams:
|
| 62 |
-
for w, c in bigrams[last_word].items():
|
| 63 |
-
if len(w) < 3 or w == last_word:
|
| 64 |
-
continue
|
| 65 |
-
candidates.append((w, c))
|
| 66 |
-
|
| 67 |
-
if not candidates:
|
| 68 |
-
for w, c in unigrams.items():
|
| 69 |
-
if len(w) < 3:
|
| 70 |
-
continue
|
| 71 |
-
candidates.append((w, c))
|
| 72 |
-
|
| 73 |
-
total = sum(c for _, c in candidates)
|
| 74 |
-
preds = [(w, c / total) for w, c in candidates]
|
| 75 |
-
preds.sort(key=lambda x: x[1], reverse=True)
|
| 76 |
-
preds = merge_similar_predictions(preds, top_k=top_k)
|
| 77 |
-
return preds[:top_k]
|
| 78 |
-
|
| 79 |
-
# ---------- Hybrid autocomplete ----------
|
| 80 |
-
def hybrid_autocomplete(prefix, unigrams, bigrams, tokenizer, model, alpha=0.6, k=5):
|
| 81 |
-
words = prefix.strip().split()
|
| 82 |
-
if len(words) < 1:
|
| 83 |
-
return []
|
| 84 |
-
|
| 85 |
-
last_word = words[-1]
|
| 86 |
-
if last_word not in bigrams:
|
| 87 |
-
return []
|
| 88 |
-
|
| 89 |
-
# -------- Statistical (Bigram) --------
|
| 90 |
-
stat_candidates = statistical_autocomplete(
|
| 91 |
-
prefix,
|
| 92 |
-
unigrams,
|
| 93 |
-
bigrams,
|
| 94 |
-
top_k=20
|
| 95 |
-
)
|
| 96 |
-
|
| 97 |
-
# -------- Neural (GPT-2) — ONCE --------
|
| 98 |
-
gpt2_probs = gpt2_next_token_probs(prefix, tokenizer, model, top_k=50)
|
| 99 |
-
|
| 100 |
-
# -------- Hybrid scoring --------
|
| 101 |
-
results = []
|
| 102 |
-
for w, stat_p in stat_candidates:
|
| 103 |
-
neural_p = gpt2_probs.get(w, 1e-8) # small value if not found
|
| 104 |
-
score = alpha * stat_p + (1 - alpha) * neural_p
|
| 105 |
-
results.append((w, score))
|
| 106 |
-
|
| 107 |
-
return sorted(results, key=lambda x: x[1], reverse=True)[:k]
|
| 108 |
-
|
| 109 |
-
import re
|
| 110 |
-
from collections import defaultdict
|
| 111 |
-
|
| 112 |
-
def canonical_form(word):
|
| 113 |
-
word = re.sub("[إأآا]", "ا", word)
|
| 114 |
-
word = re.sub("ى", "ي", word)
|
| 115 |
-
word = re.sub("ؤ", "و", word)
|
| 116 |
-
word = re.sub("ئ", "ي", word)
|
| 117 |
-
word = re.sub("ة", "ه", word)
|
| 118 |
-
word = re.sub(r"[ًٌٍَُِّْ]", "", word)
|
| 119 |
-
return word
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def merge_similar_predictions(preds, top_k=20):
|
| 124 |
-
groups = defaultdict(lambda: {"score": 0.0, "words": []})
|
| 125 |
-
|
| 126 |
-
for w, p in preds:
|
| 127 |
-
key = canonical_form(w)
|
| 128 |
-
groups[key]["score"] += p
|
| 129 |
-
groups[key]["words"].append(w)
|
| 130 |
-
|
| 131 |
-
merged = sorted(
|
| 132 |
-
groups.values(),
|
| 133 |
-
key=lambda x: x["score"],
|
| 134 |
-
reverse=True
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
return [
|
| 138 |
-
(group["words"][0], group["score"])
|
| 139 |
-
for group in merged[:top_k]
|
| 140 |
-
]
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app.py
CHANGED
|
@@ -831,14 +831,6 @@ def _is_small_spelling_change(orig_word, corr_word, vocab_manager=None):
|
|
| 831 |
if corr_word == orig_word[:-1] or len(corr_word) < len(orig_word):
|
| 832 |
return 0.0
|
| 833 |
|
| 834 |
-
# ── GUARD 3: Word truncation protection (Phase 4, P3) ──
|
| 835 |
-
# Reject corrections that significantly shorten a valid word.
|
| 836 |
-
# E.g. الدمث→الدم (rare word truncated to different word).
|
| 837 |
-
if vocab_manager and len(orig_word) - len(corr_word) >= 2:
|
| 838 |
-
if vocab_manager.is_iv(orig_word):
|
| 839 |
-
logger.info(f"[SPELLING] Blocked truncation: '{orig_word}'→'{corr_word}' (valid word shortened by {len(orig_word) - len(corr_word)} chars)")
|
| 840 |
-
return 0.0
|
| 841 |
-
|
| 842 |
# CRITICAL: If both words are valid Arabic words, only accept known fixes.
|
| 843 |
# This prevents the spelling model from changing one correct word to another
|
| 844 |
# (e.g. وكان→وكأن, which changes "and was" to "as if" — a meaning change).
|
|
@@ -1338,26 +1330,6 @@ def analyze_text():
|
|
| 1338 |
logger.error(traceback.format_exc())
|
| 1339 |
timing_ms['spelling_error'] = f"{type(e).__name__}: {str(e)[:200]}"
|
| 1340 |
|
| 1341 |
-
# ── Standalone hamza-fix pass (P1/P2) ──
|
| 1342 |
-
# fix_common_hamza handles common hamza errors that AraSpell missed
|
| 1343 |
-
# in sentence context: انت→أنت, الان→الآن, ام→أم, انا→أنا, etc.
|
| 1344 |
-
# This runs on the FULL text regardless of whether AraSpell ran.
|
| 1345 |
-
try:
|
| 1346 |
-
from nlp.spelling.araspell_rules import AraSpellPostProcessor
|
| 1347 |
-
hamza_fixed = AraSpellPostProcessor.fix_common_hamza(current_text)
|
| 1348 |
-
if hamza_fixed != current_text:
|
| 1349 |
-
hamza_diffs = get_word_diffs(current_text, hamza_fixed)
|
| 1350 |
-
for hd in hamza_diffs:
|
| 1351 |
-
ctx.add_patch(
|
| 1352 |
-
'spelling', hd['start'], hd['end'],
|
| 1353 |
-
hd['correction'], confidence=0.95,
|
| 1354 |
-
)
|
| 1355 |
-
logger.info(f"[HAMZA-FIX] '{hd.get('original','')}' → '{hd.get('correction','')}'")
|
| 1356 |
-
ctx.mutate_text(hamza_fixed, OffsetMapper)
|
| 1357 |
-
current_text = ctx.current_text
|
| 1358 |
-
except Exception as e:
|
| 1359 |
-
logger.error(f"[ANALYZE] Hamza fix failed: {type(e).__name__}: {e}")
|
| 1360 |
-
|
| 1361 |
# 2. Grammar (runs on spelling-corrected text — word-level dependency)
|
| 1362 |
try:
|
| 1363 |
t0 = time.time()
|
|
@@ -1369,40 +1341,7 @@ def analyze_text():
|
|
| 1369 |
logger.info(f"[ANALYZE] Step 2: Grammar done in {timing_ms['grammar_ms']}ms")
|
| 1370 |
if corrected_grammar != ctx.current_text:
|
| 1371 |
diffs = get_word_diffs(ctx.current_text, corrected_grammar)
|
| 1372 |
-
|
| 1373 |
-
# ── Phase 2 (P1): Split multi-word grammar diffs ──
|
| 1374 |
-
# Break merged diffs like "الي المدرسه الاستاذ"→"إلى المدرسة الأستاذ"
|
| 1375 |
-
# into individual word-level suggestions.
|
| 1376 |
-
expanded_diffs = []
|
| 1377 |
for d in diffs:
|
| 1378 |
-
orig_text = d.get('original', '')
|
| 1379 |
-
corr_text = d.get('correction', '')
|
| 1380 |
-
orig_words_list = orig_text.split()
|
| 1381 |
-
corr_words_list = corr_text.split()
|
| 1382 |
-
# Only split if same word count and multiple words
|
| 1383 |
-
if (len(orig_words_list) > 1
|
| 1384 |
-
and len(orig_words_list) == len(corr_words_list)):
|
| 1385 |
-
# Split into per-word diffs
|
| 1386 |
-
search_from = d['start']
|
| 1387 |
-
for ow, cw in zip(orig_words_list, corr_words_list):
|
| 1388 |
-
if ow != cw:
|
| 1389 |
-
w_start = ctx.current_text.find(ow, search_from)
|
| 1390 |
-
if w_start >= 0:
|
| 1391 |
-
w_end = w_start + len(ow)
|
| 1392 |
-
expanded_diffs.append({
|
| 1393 |
-
'start': w_start, 'end': w_end,
|
| 1394 |
-
'original': ow, 'correction': cw,
|
| 1395 |
-
'type': 'generic'
|
| 1396 |
-
})
|
| 1397 |
-
search_from = w_end
|
| 1398 |
-
else:
|
| 1399 |
-
search_from += len(ow) + 1
|
| 1400 |
-
else:
|
| 1401 |
-
search_from += len(ow) + 1
|
| 1402 |
-
else:
|
| 1403 |
-
expanded_diffs.append(d)
|
| 1404 |
-
|
| 1405 |
-
for d in expanded_diffs:
|
| 1406 |
# StageLocker: skip diffs that overlap with locked ranges
|
| 1407 |
if ctx.stage_locker.is_locked(d['start'], d['end']):
|
| 1408 |
logger.info(
|
|
@@ -1411,25 +1350,6 @@ def analyze_text():
|
|
| 1411 |
)
|
| 1412 |
continue
|
| 1413 |
|
| 1414 |
-
# ── Punctuation-protection guard ──
|
| 1415 |
-
# Grammar model sometimes strips/normalizes punctuation
|
| 1416 |
-
# (removes full stops, commas, etc.). Reject grammar diffs
|
| 1417 |
-
# that only differ in punctuation marks — grammar should
|
| 1418 |
-
# only fix grammar, not touch punctuation.
|
| 1419 |
-
_orig_t = d.get('original', '')
|
| 1420 |
-
_corr_t = d.get('correction', '')
|
| 1421 |
-
_PUNC_CHARS = set('.,;:!?؟،؛。·…•–—\u200f\u200e')
|
| 1422 |
-
_orig_no_punc = ''.join(c for c in _orig_t if c not in _PUNC_CHARS)
|
| 1423 |
-
_corr_no_punc = ''.join(c for c in _corr_t if c not in _PUNC_CHARS)
|
| 1424 |
-
if _orig_no_punc == _corr_no_punc:
|
| 1425 |
-
# Only punctuation was changed — block it
|
| 1426 |
-
if _orig_t != _corr_t:
|
| 1427 |
-
logger.info(
|
| 1428 |
-
f"[GRAMMAR] Blocked punctuation change: "
|
| 1429 |
-
f"'{_orig_t}' → '{_corr_t}' (grammar must not alter punctuation)"
|
| 1430 |
-
)
|
| 1431 |
-
continue
|
| 1432 |
-
|
| 1433 |
# Reject grammar hallucinations (e.g. جالس→جاكسون)
|
| 1434 |
orig_text = d.get('original', '')
|
| 1435 |
corr_text = d.get('correction', '')
|
|
|
|
| 831 |
if corr_word == orig_word[:-1] or len(corr_word) < len(orig_word):
|
| 832 |
return 0.0
|
| 833 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 834 |
# CRITICAL: If both words are valid Arabic words, only accept known fixes.
|
| 835 |
# This prevents the spelling model from changing one correct word to another
|
| 836 |
# (e.g. وكان→وكأن, which changes "and was" to "as if" — a meaning change).
|
|
|
|
| 1330 |
logger.error(traceback.format_exc())
|
| 1331 |
timing_ms['spelling_error'] = f"{type(e).__name__}: {str(e)[:200]}"
|
| 1332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1333 |
# 2. Grammar (runs on spelling-corrected text — word-level dependency)
|
| 1334 |
try:
|
| 1335 |
t0 = time.time()
|
|
|
|
| 1341 |
logger.info(f"[ANALYZE] Step 2: Grammar done in {timing_ms['grammar_ms']}ms")
|
| 1342 |
if corrected_grammar != ctx.current_text:
|
| 1343 |
diffs = get_word_diffs(ctx.current_text, corrected_grammar)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1344 |
for d in diffs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1345 |
# StageLocker: skip diffs that overlap with locked ranges
|
| 1346 |
if ctx.stage_locker.is_locked(d['start'], d['end']):
|
| 1347 |
logger.info(
|
|
|
|
| 1350 |
)
|
| 1351 |
continue
|
| 1352 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1353 |
# Reject grammar hallucinations (e.g. جالس→جاكسون)
|
| 1354 |
orig_text = d.get('original', '')
|
| 1355 |
corr_text = d.get('correction', '')
|
trace_output.txt
DELETED
|
File without changes
|
trace_punc.py
DELETED
|
@@ -1,73 +0,0 @@
|
|
| 1 |
-
import sys, os, re
|
| 2 |
-
sys.path.insert(0, 'src')
|
| 3 |
-
import logging; logging.basicConfig(level=logging.INFO)
|
| 4 |
-
print("Starting...")
|
| 5 |
-
|
| 6 |
-
import torch
|
| 7 |
-
print(f"CUDA available: {torch.cuda.is_available()}")
|
| 8 |
-
|
| 9 |
-
from transformers import EncoderDecoderModel, AutoTokenizer
|
| 10 |
-
print("Loading PuncAra-v1...")
|
| 11 |
-
model = EncoderDecoderModel.from_pretrained("bayan10/PuncAra-v1")
|
| 12 |
-
tokenizer = AutoTokenizer.from_pretrained("bayan10/PuncAra-v1")
|
| 13 |
-
model.eval()
|
| 14 |
-
print("Model loaded!")
|
| 15 |
-
|
| 16 |
-
inp = "التزم الرياضي بتناول وجباته الصحية وحساب سعراته بدقة رغبة في بناء كتلة عضلية قوية ويا له من التزام حديدي يثير الإعجاب"
|
| 17 |
-
print(f"\nINPUT: {inp}")
|
| 18 |
-
|
| 19 |
-
# Raw inference
|
| 20 |
-
from nlp.punctuation.punctuation_rules import arabic_preprocessing
|
| 21 |
-
processed = arabic_preprocessing(inp)
|
| 22 |
-
inputs = tokenizer(processed, return_tensors="pt", padding=True, truncation=True, max_length=128)
|
| 23 |
-
print("Running inference...")
|
| 24 |
-
with torch.no_grad():
|
| 25 |
-
outputs = model.generate(
|
| 26 |
-
inputs.input_ids,
|
| 27 |
-
attention_mask=inputs.attention_mask,
|
| 28 |
-
decoder_start_token_id=tokenizer.cls_token_id,
|
| 29 |
-
bos_token_id=tokenizer.cls_token_id,
|
| 30 |
-
eos_token_id=tokenizer.sep_token_id,
|
| 31 |
-
pad_token_id=tokenizer.pad_token_id,
|
| 32 |
-
max_length=128, num_beams=3, repetition_penalty=1.2,
|
| 33 |
-
length_penalty=1.0, early_stopping=True, do_sample=False
|
| 34 |
-
)
|
| 35 |
-
raw = tokenizer.decode(outputs[0], skip_special_tokens=True)
|
| 36 |
-
print(f"[A] RAW MODEL: {raw}")
|
| 37 |
-
|
| 38 |
-
# Strip non-punc
|
| 39 |
-
from nlp.punctuation.punctuation_service import PunctuationChecker
|
| 40 |
-
checker = PunctuationChecker(model, tokenizer, torch.device('cpu'))
|
| 41 |
-
stripped = checker._strip_non_punctuation_changes(inp, raw)
|
| 42 |
-
print(f"[B] STRIPPED: {stripped}")
|
| 43 |
-
if stripped != raw:
|
| 44 |
-
rw, sw = raw.split(), stripped.split()
|
| 45 |
-
for w1, w2 in zip(rw, sw):
|
| 46 |
-
if w1 != w2:
|
| 47 |
-
print(f" LOST: '{w1}' -> '{w2}'")
|
| 48 |
-
|
| 49 |
-
# Postprocess
|
| 50 |
-
from nlp.punctuation.punctuation_rules import arabic_postprocessing
|
| 51 |
-
final = arabic_postprocessing(stripped)
|
| 52 |
-
print(f"[C] FINAL: {final}")
|
| 53 |
-
|
| 54 |
-
# Diffs
|
| 55 |
-
from app import get_word_diffs
|
| 56 |
-
from nlp.punctuation.punctuation_rules import validate_punctuation_diff
|
| 57 |
-
if final != inp:
|
| 58 |
-
diffs = get_word_diffs(inp, final)
|
| 59 |
-
print(f"[D] DIFFS ({len(diffs)}):")
|
| 60 |
-
for d in diffs:
|
| 61 |
-
o, c = d.get('original',''), d.get('correction','')
|
| 62 |
-
valid = validate_punctuation_diff(d)
|
| 63 |
-
oa = re.sub(r'[^\u0600-\u06FFa-zA-Z]','',o)
|
| 64 |
-
ca = re.sub(r'[^\u0600-\u06FFa-zA-Z]','',c)
|
| 65 |
-
alpha_ok = oa == ca
|
| 66 |
-
s = "PASS" if valid and alpha_ok else "BLOCKED"
|
| 67 |
-
r = ""
|
| 68 |
-
if not valid: r += " safety"
|
| 69 |
-
if not alpha_ok: r += " alpha"
|
| 70 |
-
print(f" [{d['start']}:{d['end']}] '{o}' -> '{c}' [{s}{r}]")
|
| 71 |
-
else:
|
| 72 |
-
print("[D] NO DIFFS!")
|
| 73 |
-
print("\nDONE")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
trace_punctuation.py
DELETED
|
@@ -1,176 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
BAYAN Punctuation Trace — Diagnose where punctuation marks get lost.
|
| 3 |
-
|
| 4 |
-
Compares:
|
| 5 |
-
A) Raw PuncAra model output (no pipeline)
|
| 6 |
-
B) After _strip_non_punctuation_changes (Fix P1)
|
| 7 |
-
C) After get_word_diffs (diff algorithm)
|
| 8 |
-
D) After StageLocker check
|
| 9 |
-
E) After validate_punctuation_diff (safety layer)
|
| 10 |
-
F) After overlap resolver + patch cap
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import sys, os, re, difflib
|
| 14 |
-
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
| 15 |
-
|
| 16 |
-
# Suppress model loading noise
|
| 17 |
-
import logging
|
| 18 |
-
logging.basicConfig(level=logging.WARNING)
|
| 19 |
-
|
| 20 |
-
# ─── Test Sentences ─────────────────────────────────────────────
|
| 21 |
-
TEST_SENTENCES = [
|
| 22 |
-
{
|
| 23 |
-
"input": "التزم الرياضي بتناول وجباته الصحية وحساب سعراته بدقة رغبة في بناء كتلة عضلية قوية ويا له من التزام حديدي يثير الإعجاب",
|
| 24 |
-
"expected": "التزم الرياضي بتناول وجباته الصحية وحساب سعراته بدقة؛ رغبة في بناء كتلة عضلية قوية، ويا له من التزام حديدي يثير الإعجاب!",
|
| 25 |
-
},
|
| 26 |
-
{
|
| 27 |
-
"input": "كانت الفتيات يلعبن في الحديقة وفجأة سقطت إحداهن وبدأت تبكي بشدة",
|
| 28 |
-
"expected": "كانت الفتيات يلعبن في الحديقة، وفجأة سقطت إحداهن وبدأت تبكي بشدة.",
|
| 29 |
-
},
|
| 30 |
-
{
|
| 31 |
-
"input": "إن الذكاء الاصطناعي يلعب دورا هاما لذلك يجب الاهتمام به",
|
| 32 |
-
"expected": "إن الذكاء الاصطناعي يلعب دورا هاما؛ لذلك يجب الاهتمام به.",
|
| 33 |
-
},
|
| 34 |
-
{
|
| 35 |
-
"input": "هل تعلم أن القاهرة هي عاصمة مصر وتقع على ضفاف نهر النيل",
|
| 36 |
-
"expected": "هل تعلم أن القاهرة هي عاصمة مصر، وتقع على ضفاف نهر النيل؟",
|
| 37 |
-
},
|
| 38 |
-
{
|
| 39 |
-
"input": "قال المعلم للطلاب ادرسوا جيدا فالامتحان قريب",
|
| 40 |
-
"expected": "قال المعلم للطلاب: ادرسوا جيدا، فالامتحان قريب.",
|
| 41 |
-
},
|
| 42 |
-
]
|
| 43 |
-
|
| 44 |
-
def count_punct(text):
|
| 45 |
-
"""Count punctuation marks in text."""
|
| 46 |
-
marks = set('.,;:!?،؛؟')
|
| 47 |
-
return sum(1 for c in text if c in marks)
|
| 48 |
-
|
| 49 |
-
def diff_punct(before, after):
|
| 50 |
-
"""Show what punctuation marks were added/removed."""
|
| 51 |
-
marks = set('.,;:!?،؛؟')
|
| 52 |
-
before_marks = [(i, c) for i, c in enumerate(before) if c in marks]
|
| 53 |
-
after_marks = [(i, c) for i, c in enumerate(after) if c in marks]
|
| 54 |
-
return before_marks, after_marks
|
| 55 |
-
|
| 56 |
-
def main():
|
| 57 |
-
print("=" * 80)
|
| 58 |
-
print("BAYAN PUNCTUATION TRACE — Where do punctuation marks get lost?")
|
| 59 |
-
print("=" * 80)
|
| 60 |
-
|
| 61 |
-
# Load model
|
| 62 |
-
print("\n[1/2] Loading PuncAra-v1 model...")
|
| 63 |
-
from nlp.punctuation.punctuation_service import get_punctuation_model, PunctuationChecker
|
| 64 |
-
punc_checker = get_punctuation_model()
|
| 65 |
-
print(" ✓ Model loaded\n")
|
| 66 |
-
|
| 67 |
-
# Load pipeline tools
|
| 68 |
-
print("[2/2] Loading pipeline tools...")
|
| 69 |
-
from app import get_word_diffs
|
| 70 |
-
from nlp.punctuation.punctuation_rules import validate_punctuation_diff
|
| 71 |
-
print(" ✓ Tools loaded\n")
|
| 72 |
-
|
| 73 |
-
for idx, test in enumerate(TEST_SENTENCES):
|
| 74 |
-
inp = test["input"]
|
| 75 |
-
expected = test["expected"]
|
| 76 |
-
|
| 77 |
-
print("─" * 80)
|
| 78 |
-
print(f"TEST {idx+1}")
|
| 79 |
-
print(f" INPUT: {inp}")
|
| 80 |
-
print(f" EXPECTED: {expected}")
|
| 81 |
-
print(f" Expected marks: {count_punct(expected)}")
|
| 82 |
-
print()
|
| 83 |
-
|
| 84 |
-
# ─── Stage A: Raw model output (no post-processing) ────────
|
| 85 |
-
raw_output = punc_checker._fix_punctuation(inp)
|
| 86 |
-
print(f" [A] RAW MODEL: {raw_output}")
|
| 87 |
-
print(f" Marks added: {count_punct(raw_output) - count_punct(inp)}")
|
| 88 |
-
print()
|
| 89 |
-
|
| 90 |
-
# ─── Stage B: After _strip_non_punctuation_changes ─────────
|
| 91 |
-
stripped = punc_checker._strip_non_punctuation_changes(inp, raw_output)
|
| 92 |
-
print(f" [B] STRIP NON-PUNC: {stripped}")
|
| 93 |
-
if stripped != raw_output:
|
| 94 |
-
print(f" ⚠ Changes stripped! Diff from raw:")
|
| 95 |
-
for w1, w2 in zip(raw_output.split(), stripped.split()):
|
| 96 |
-
if w1 != w2:
|
| 97 |
-
print(f" '{w1}' → '{w2}'")
|
| 98 |
-
print(f" Marks added: {count_punct(stripped) - count_punct(inp)}")
|
| 99 |
-
print()
|
| 100 |
-
|
| 101 |
-
# ─── Stage C: get_word_diffs ───────────────────────────────
|
| 102 |
-
# This is what correct() returns after postprocessing
|
| 103 |
-
from nlp.punctuation.punctuation_rules import arabic_postprocessing
|
| 104 |
-
final_punc = arabic_postprocessing(stripped)
|
| 105 |
-
|
| 106 |
-
print(f" [C] FINAL PUNC OUT: {final_punc}")
|
| 107 |
-
print(f" Marks added: {count_punct(final_punc) - count_punct(inp)}")
|
| 108 |
-
print()
|
| 109 |
-
|
| 110 |
-
# ─── Stage D: Word diffs ──────────────────────────────────
|
| 111 |
-
if final_punc != inp:
|
| 112 |
-
diffs = get_word_diffs(inp, final_punc)
|
| 113 |
-
print(f" [D] WORD DIFFS ({len(diffs)} found):")
|
| 114 |
-
for d in diffs:
|
| 115 |
-
orig = d.get('original', '')
|
| 116 |
-
corr = d.get('correction', '')
|
| 117 |
-
|
| 118 |
-
# Check validate_punctuation_diff
|
| 119 |
-
is_valid = validate_punctuation_diff(d)
|
| 120 |
-
|
| 121 |
-
# Check alpha match (lock bypass)
|
| 122 |
-
orig_alpha = re.sub(r'[^\u0600-\u06FFa-zA-Z]', '', orig)
|
| 123 |
-
corr_alpha = re.sub(r'[^\u0600-\u06FFa-zA-Z]', '', corr)
|
| 124 |
-
alpha_match = orig_alpha == corr_alpha
|
| 125 |
-
|
| 126 |
-
status_parts = []
|
| 127 |
-
if not is_valid:
|
| 128 |
-
status_parts.append("❌ SAFETY-REJECTED")
|
| 129 |
-
if not alpha_match:
|
| 130 |
-
status_parts.append("❌ LOCK-BLOCKED (alpha differs)")
|
| 131 |
-
if is_valid and alpha_match:
|
| 132 |
-
status_parts.append("✅ WOULD PASS")
|
| 133 |
-
elif is_valid:
|
| 134 |
-
status_parts.append("✅ valid-punc")
|
| 135 |
-
|
| 136 |
-
status = " | ".join(status_parts)
|
| 137 |
-
print(f" [{d['start']}:{d['end']}] '{orig}' → '{corr}' {status}")
|
| 138 |
-
else:
|
| 139 |
-
print(f" [D] NO DIFFS — model returned same text as input!")
|
| 140 |
-
|
| 141 |
-
print()
|
| 142 |
-
|
| 143 |
-
# ─── Summary ───────────────────────────────────────────────────
|
| 144 |
-
print("=" * 80)
|
| 145 |
-
print("LOSS POINTS SUMMARY")
|
| 146 |
-
print("=" * 80)
|
| 147 |
-
print("""
|
| 148 |
-
Where punctuation marks can be lost:
|
| 149 |
-
|
| 150 |
-
[A→B] _strip_non_punctuation_changes():
|
| 151 |
-
If model changes a word's spelling AND adds punctuation,
|
| 152 |
-
the punctuation transfer logic may fail.
|
| 153 |
-
|
| 154 |
-
[B→C] arabic_postprocessing():
|
| 155 |
-
Typographic cleanup may remove valid marks.
|
| 156 |
-
|
| 157 |
-
[C→D] get_word_diffs():
|
| 158 |
-
Word-level diff may merge/split changes incorrectly.
|
| 159 |
-
|
| 160 |
-
[D→E] StageLocker:
|
| 161 |
-
Locked ranges from spelling/grammar block nearby punctuation.
|
| 162 |
-
(Now relaxed: pure-punc changes pass through)
|
| 163 |
-
|
| 164 |
-
[D→E] validate_punctuation_diff():
|
| 165 |
-
Safety layer rejects diffs that change Arabic text.
|
| 166 |
-
|
| 167 |
-
[E→F] Overlap resolver:
|
| 168 |
-
Grammar/spelling patches take priority over punctuation.
|
| 169 |
-
|
| 170 |
-
[E→F] Patch cap:
|
| 171 |
-
Max 3 punctuation patches per response.
|
| 172 |
-
""")
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
if __name__ == "__main__":
|
| 176 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|