| """ |
| BAYAN v2.0 — Level 3: Integrated Pipeline Tests |
| ================================================= |
| Tests the FULL integrated pipeline through /api/analyze. |
| This is the end-to-end test: Spelling → Grammar → Punctuation |
| with all filters, StageLocker, OffsetMapper, PatchSet. |
| |
| Reuses the exact same verdict logic as the existing benchmark_runner.py |
| to ensure comparability. |
| |
| Usage: |
| python tests/v2/test_level3_integrated.py --url URL [--dataset DATASET] |
| """ |
| import argparse |
| import json |
| import re |
| import time |
| from pathlib import Path |
| from dataclasses import dataclass, asdict |
| from typing import List |
| import requests |
|
|
| DATASETS_DIR = Path(__file__).parent.parent / "phase10" / "gold_datasets" |
| REPORT_DIR = Path(__file__).parent / "reports" |
|
|
|
|
| def normalize(text): |
| t = re.sub(r'[\u064B-\u065F\u0670]', '', text) |
| t = re.sub(r'\s+', ' ', t).strip() |
| return t |
|
|
|
|
| @dataclass |
| class IntegratedResult: |
| id: str |
| dataset: str |
| category: str |
| input_text: str |
| expected: str |
| severity: str |
| |
| pipeline_corrected: str = "" |
| pipeline_suggestions: int = 0 |
| pipeline_ms: int = 0 |
| spelling_ms: int = 0 |
| grammar_ms: int = 0 |
| punctuation_ms: int = 0 |
| |
| verdict: str = "" |
| detail: str = "" |
|
|
|
|
| class APIClient: |
| def __init__(self, base_url): |
| self.base = base_url.rstrip('/') |
| self.session = requests.Session() |
| self.session.headers['Content-Type'] = 'application/json' |
|
|
| def analyze(self, text, timeout=120): |
| t0 = time.time() |
| try: |
| r = self.session.post( |
| f"{self.base}/api/analyze", |
| json={"text": text}, |
| timeout=timeout |
| ) |
| ms = int((time.time() - t0) * 1000) |
| return r.json(), ms |
| except Exception as e: |
| ms = int((time.time() - t0) * 1000) |
| return {"error": str(e)}, ms |
|
|
|
|
| def classify_pipeline(input_text, corrected_text, expected_text, dataset, entity=None): |
| """Classify pipeline output. |
| |
| For correction datasets (spelling, grammar, punctuation, collision): |
| Input has errors → should be corrected to match expected. |
| |
| For preservation datasets (entities, religious, structured, hallucination): |
| Input is correct → should NOT be modified. |
| For entity tests: specifically check that the entity string is preserved. |
| """ |
| inp_n = normalize(input_text) |
| out_n = normalize(corrected_text) |
| exp_n = normalize(expected_text) |
| |
| is_preservation = dataset in ('entities', 'religious', 'structured', 'hallucination') |
| text_changed = (out_n != inp_n) |
| |
| if dataset == 'entities' and entity: |
| |
| entity_n = normalize(entity) |
| if entity_n in out_n: |
| return "TN", "Entity preserved" |
| elif not text_changed: |
| return "TN", "Text unchanged" |
| else: |
| return "FP", f"ENTITY CORRUPTED: '{entity}' missing from output" |
| |
| if is_preservation: |
| if not text_changed: |
| return "TN", "Text correctly preserved" |
| else: |
| |
| inp_words = inp_n.split() |
| out_words = out_n.split() |
| changes = [] |
| for iw, ow in zip(inp_words, out_words): |
| if iw != ow: |
| changes.append(f"{iw}→{ow}") |
| detail = f"Text modified: {changes[:5]}" |
| return "FP", detail |
| else: |
| |
| needs_correction = (inp_n != exp_n) |
| |
| |
| |
| _TERMINAL_PUNCT = '.،؛؟!?!' |
| out_stripped = out_n.rstrip(_TERMINAL_PUNCT).rstrip() |
| |
| if needs_correction: |
| if out_n == exp_n or out_stripped == exp_n: |
| return "TP", "Exact match" |
| elif text_changed and _closer(out_stripped, inp_n, exp_n): |
| return "TP", "Partial improvement" |
| elif text_changed and _closer(out_n, inp_n, exp_n): |
| return "TP", "Partial improvement (with punct)" |
| elif not text_changed: |
| return "FN", "No correction applied" |
| else: |
| return "FP", f"Wrong correction" |
| else: |
| if not text_changed: |
| return "TN", "Correctly unchanged" |
| elif out_stripped == inp_n: |
| |
| return "TN", "Only punctuation added" |
| else: |
| return "FP", f"Modified correct text" |
|
|
|
|
| def _closer(output, input_text, expected): |
| """Is output closer to expected than input was?""" |
| d_out = _edit_distance(output, expected) |
| d_inp = _edit_distance(input_text, expected) |
| return d_out < d_inp |
|
|
|
|
| def _edit_distance(a, b): |
| if len(a) < len(b): |
| return _edit_distance(b, a) |
| if len(b) == 0: |
| return len(a) |
| prev = list(range(len(b) + 1)) |
| for i, ca in enumerate(a): |
| curr = [i + 1] |
| for j, cb in enumerate(b): |
| cost = 0 if ca == cb else 1 |
| curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost)) |
| prev = curr |
| return prev[len(b)] |
|
|
|
|
| def load_datasets(dataset_filter=None): |
| datasets = {} |
| for f in sorted(DATASETS_DIR.glob("*.json")): |
| name = f.stem |
| if dataset_filter and name != dataset_filter: |
| continue |
| with open(f, 'r', encoding='utf-8') as fh: |
| datasets[name] = json.load(fh) |
| return datasets |
|
|
|
|
| def run_level3(api: APIClient, datasets: dict) -> List[IntegratedResult]: |
| results = [] |
| total = sum(len(v) for v in datasets.values()) |
| idx = 0 |
|
|
| for ds_name, cases in datasets.items(): |
| print(f"\n{'='*60}") |
| print(f"DATASET: {ds_name.upper()} ({len(cases)} samples)") |
| print(f"{'='*60}") |
|
|
| tp = tn = fp = fn = 0 |
| for case in cases: |
| idx += 1 |
| cid = case.get('id', f'{ds_name}_{idx}') |
| cat = case.get('category', '') |
| inp = case.get('input', '') |
| expected = case.get('expected', case.get('input', '')) |
| severity = case.get('severity', '') |
| entity = case.get('entity', None) |
|
|
| r = IntegratedResult( |
| id=cid, dataset=ds_name, category=cat, |
| input_text=inp, expected=expected, severity=severity |
| ) |
|
|
| print(f" [{idx}/{total}] {cid} ({cat})...", end=" ", flush=True) |
|
|
| data, ms = api.analyze(inp) |
| r.pipeline_ms = ms |
| r.pipeline_corrected = data.get('corrected', inp) |
| r.pipeline_suggestions = len(data.get('suggestions', [])) |
| timing = data.get('timing_ms', {}) |
| r.spelling_ms = timing.get('spelling_ms', 0) |
| r.grammar_ms = timing.get('grammar_ms', 0) |
| r.punctuation_ms = timing.get('punctuation_ms', 0) |
|
|
| r.verdict, r.detail = classify_pipeline( |
| inp, r.pipeline_corrected, expected, ds_name, entity |
| ) |
|
|
| icon = {"TP": "✅", "TN": "✅", "FP": "❌", "FN": "⚠️"}.get(r.verdict, "?") |
| print(f"{icon} {r.verdict} ({r.pipeline_ms}ms)") |
|
|
| if r.verdict == "TP": tp += 1 |
| elif r.verdict == "TN": tn += 1 |
| elif r.verdict == "FP": fp += 1 |
| elif r.verdict == "FN": fn += 1 |
|
|
| results.append(r) |
|
|
| total_ds = tp + tn + fp + fn |
| pass_pct = (tp + tn) / total_ds * 100 if total_ds else 0 |
| print(f"\n Pass={pass_pct:.1f}% TP={tp} TN={tn} FP={fp} FN={fn}") |
|
|
| return results |
|
|
|
|
| def analyze_and_print(results: List[IntegratedResult]) -> dict: |
| verdicts = {"TP": 0, "TN": 0, "FP": 0, "FN": 0} |
| by_dataset = {} |
|
|
| for r in results: |
| verdicts[r.verdict] = verdicts.get(r.verdict, 0) + 1 |
| if r.dataset not in by_dataset: |
| by_dataset[r.dataset] = {"TP": 0, "TN": 0, "FP": 0, "FN": 0, "total": 0} |
| by_dataset[r.dataset][r.verdict] += 1 |
| by_dataset[r.dataset]["total"] += 1 |
|
|
| total = sum(verdicts.values()) |
| pass_count = verdicts["TP"] + verdicts["TN"] |
|
|
| analysis = { |
| "total": total, |
| "aggregate": { |
| **verdicts, |
| "pass_rate": round(pass_count / total, 4) if total else 0, |
| }, |
| "by_dataset": {}, |
| } |
|
|
| print(f"\n{'='*60}") |
| print("LEVEL 3: INTEGRATED PIPELINE ANALYSIS") |
| print(f"{'='*60}") |
| print(f"\n Total: {total} | Pass: {pass_count}/{total} ({analysis['aggregate']['pass_rate']*100:.1f}%)") |
| print(f" TP={verdicts['TP']} TN={verdicts['TN']} FP={verdicts['FP']} FN={verdicts['FN']}") |
|
|
| print(f"\n| Dataset | Total | TP | TN | FP | FN | Pass% |") |
| print(f"|--------------|-------|-----|-----|-----|-----|--------|") |
| for ds in sorted(by_dataset.keys()): |
| d = by_dataset[ds] |
| p = (d["TP"] + d["TN"]) / d["total"] * 100 if d["total"] else 0 |
| print(f"| {ds:<12} | {d['total']:>5} | {d['TP']:>3} | {d['TN']:>3} | {d['FP']:>3} | {d['FN']:>3} | {p:5.1f}% |") |
| analysis["by_dataset"][ds] = {**d, "pass_rate": round(p / 100, 4)} |
|
|
| return analysis |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="Level 3: Integrated Pipeline Tests") |
| parser.add_argument("--url", default="https://bayan10-bayan-api.hf.space") |
| parser.add_argument("--dataset", default=None) |
| args = parser.parse_args() |
|
|
| api = APIClient(args.url) |
| datasets = load_datasets(args.dataset) |
|
|
| print(f"\n{'='*60}") |
| print("BAYAN v2.0 — Level 3: Integrated Pipeline Tests") |
| print(f"{'='*60}") |
| print(f" Target: {args.url}") |
| print(f" Datasets: {list(datasets.keys())}") |
| print(f" Total: {sum(len(v) for v in datasets.values())} tests") |
|
|
| results = run_level3(api, datasets) |
| analysis = analyze_and_print(results) |
|
|
| REPORT_DIR.mkdir(parents=True, exist_ok=True) |
| out_path = REPORT_DIR / "level3_integrated_results.json" |
| report = { |
| "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"), |
| "target": args.url, |
| "analysis": analysis, |
| "results": [asdict(r) for r in results], |
| } |
| with open(out_path, 'w', encoding='utf-8') as f: |
| json.dump(report, f, ensure_ascii=False, indent=2) |
| print(f"\n[L3] Results → {out_path}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|