"""Annotation Repair Test - validation/repair of annotated text vs original article. Coverage: 1. Already valid (no repair needed) 2. raw_text wrongly filled with value (typical LLM error) -> rebuild 3. raw_text spelling deviation -> rebuild via difflib alignment 4. Body contains escape chars (|, }, \\) -> escape rules preserved 5. No text segment (all annotation) -> difflib can still repair 6. Insufficient body field count -> auto-fill and replace 7. Annotation at tail (no following text anchor) 8. Annotation at head (no preceding text anchor) 9. Quote hallucination: full-width "" <-> half-width "" in outer text 10. Punctuation hallucination: Chinese 。 <-> English . in outer text 11. Missing char in outer text (typical LLM omission) 12. Mixed errors: outer text + raw_text both deviate Usage: python test_annotation_repair.py """ from __future__ import annotations import sys from pathlib import Path # Normalize Windows console to UTF-8 to avoid Chinese garbled output if sys.platform == "win32": try: sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined] except Exception: pass # Make `src` importable regardless of CWD sys.path.insert(0, str(Path(__file__).parent)) from src.annotation_repair import ( # noqa: E402 parse_annotated_segments, repair_annotation, validate_annotation, ) def _ok(label: str, repaired: str | None = None) -> None: """打印 [OK] 标记;若提供了 repaired 则附带修复后文本。""" print(f"[OK] {label}") if repaired is not None: print(f" 修复后: {repaired}") # --- Scene 1: already valid -------------------------------------------------- def test_already_valid(): article = "2026年6月1日晚8点,刘阳作为总策划。" annotated = ( "{{PDJH.JBXX.PDKSSJ||2026-06-01T20:00:00.000||2026年6月1日晚8点||" "日期范围||ISO→中文}},{{PDJH.RYXX.ZCH.XM||刘阳||刘阳||2~4字||不作转换}}" "作为总策划。" ) is_valid, _ = validate_annotation(article, annotated) assert is_valid, "fully-matched annotation should pass validation" repaired, logs = repair_annotation(article, annotated) assert repaired == annotated, "matched annotation must not be modified" assert logs == [], "no logs when already valid" _ok("scene1: already valid") # --- Scene 2: raw_text wrongly filled with value (typical LLM error) --------- def test_raw_text_wrong_as_value(): article = "2026年6月1日晚8点,刘阳作为总策划。" bad_annotated = ( "{{PDJH.JBXX.PDKSSJ||2026-06-01T20:00:00.000||2026-06-01T20:00:00.000||" "日期范围||ISO→中文}},{{PDJH.RYXX.ZCH.XM||刘阳||刘阳||2~4字||不作转换}}" "作为总策划。" ) is_valid, _ = validate_annotation(article, bad_annotated) assert not is_valid, "wrongly-filled annotation should fail validation" repaired, logs = repair_annotation(article, bad_annotated) is_valid_after, _ = validate_annotation(article, repaired) assert is_valid_after, f"should pass after repair, logs: {logs}" segs = parse_annotated_segments(repaired) ann = next(s for s in segs if s["type"] == "annotation") assert ann["raw_text"] == "2026年6月1日晚8点", ( f"raw_text should be rebuilt from article, got: {ann['raw_text']!r}" ) _ok("scene2: raw_text wrongly filled with value repaired", repaired) # --- Scene 3: multiple annotations with spelling deviation ------------------- def test_raw_text_partial_deviation(): article = "活动预算为3000元,已支付2000元。" bad_annotated = ( "活动预算为{{PDJH.JBXX.PDZYS||3000.0||三千||数值||不做转换}}元," "已支付{{PDJH.JBXX.YZF||2000.0||两千||数值||不做转换}}元。" ) repaired, logs = repair_annotation(article, bad_annotated) is_valid, _ = validate_annotation(article, repaired) assert is_valid, f"should pass after repair, logs: {logs}" segs = parse_annotated_segments(repaired) anns = [s for s in segs if s["type"] == "annotation"] assert anns[0]["raw_text"] == "3000", f"first raw_text expected '3000', got: {anns[0]['raw_text']!r}" assert anns[1]["raw_text"] == "2000", f"second raw_text expected '2000', got: {anns[1]['raw_text']!r}" _ok("scene3: multiple annotations with deviation repaired", repaired) # --- Scene 4: body contains escape chars ------------------------------------- def test_body_with_escape_chars(): # Original article contains "|", must be escaped as "\|" in body article = "a|b|c 结束" # Python literal "\\|" represents the actual 2-char sequence "\|" annotated = "{{P||v||a\\|b\\|c||约束||规则1\\|规则2}} 结束" is_valid, _ = validate_annotation(article, annotated) assert is_valid, "annotated text with escape chars should match article" repaired, logs = repair_annotation(article, annotated) assert logs == [], "no repair should be triggered when already valid" assert repaired == annotated _ok("scene4: escape chars preserved") # --- Scene 5: no text segment (difflib can still repair) -------------------- def test_no_text_segment_can_repair(): """新算法用 difflib 对齐,无需 text segment 作为锚点也能修复.""" article = "刘阳" bad_annotated = "{{P||刘阳||李四||约束||规则}}" # raw_text wrongly set to "李四" is_valid, _ = validate_annotation(article, bad_annotated) assert not is_valid repaired, logs = repair_annotation(article, bad_annotated) is_valid_after, _ = validate_annotation(article, repaired) assert is_valid_after, ( f"difflib 应能修复纯标注文本,logs: {logs}" ) segs = parse_annotated_segments(repaired) ann = next(s for s in segs if s["type"] == "annotation") assert ann["raw_text"] == "刘阳", ( f"raw_text 应被重建为 '刘阳',实际:{ann['raw_text']!r}" ) _ok("scene5: no text segment - repaired by difflib alignment", repaired) # --- Scene 6: insufficient body field count ---------------------------------- def test_body_field_count_insufficient(): article = "张三参加" # Truncated body: only path||value bad_annotated = "{{P||张三}}参加" is_valid, _ = validate_annotation(article, bad_annotated) assert not is_valid, "truncated body should fail validation" repaired, logs = repair_annotation(article, bad_annotated) is_valid_after, _ = validate_annotation(article, repaired) assert is_valid_after, f"should auto-fill and repair, logs: {logs}" _ok("scene6: insufficient body field count repaired", repaired) # --- Scene 7: annotation at tail (no following text anchor) ------------------ def test_annotation_at_tail(): article = "活动结束于2026年6月1日" bad_annotated = "活动结束于{{PDJH.JBXX.JSSJ||2026-06-01||结束时间||约束||规则}}" is_valid, _ = validate_annotation(article, bad_annotated) assert not is_valid repaired, logs = repair_annotation(article, bad_annotated) is_valid_after, _ = validate_annotation(article, repaired) assert is_valid_after, f"tail annotation should repair, logs: {logs}" segs = parse_annotated_segments(repaired) ann = next(s for s in segs if s["type"] == "annotation") assert ann["raw_text"] == "2026年6月1日", ( f"tail raw_text expected '2026年6月1日', got: {ann['raw_text']!r}" ) _ok("scene7: tail annotation repaired", repaired) # --- Scene 8: annotation at head (no preceding text anchor) ------------------ def test_annotation_at_head(): article = "2026年6月1日活动开始" bad_annotated = "{{PDJH.JBXX.KSSJ||2026-06-01||错误||约束||规则}}活动开始" repaired, logs = repair_annotation(article, bad_annotated) is_valid, _ = validate_annotation(article, repaired) assert is_valid, f"head annotation should repair, logs: {logs}" _ok("scene8: head annotation repaired", repaired) # --- Scene 9: quote hallucination in outer text (user's real case) ----------- def test_quote_hallucination_in_outer_text(): """LLM 把中文双引号 \u201c\u201d 改成英文双引号 "(或反向). 这是用户实际遇到的失败案例:text segment 在原文中找不到, 旧版 text-anchored 算法会直接报错放弃。difflib 应能修复。 """ article = '指挥员仍然感觉意犹未尽:\u201c每次训练,都是一次全新挑战!\u201d' # 把中文双引号 \u201c\u201d 改为英文双引号 "(LLM 典型幻觉) bad_annotated = '指挥员仍然感觉意犹未尽:"每次训练,都是一次全新挑战!"' assert article != bad_annotated, "测试前置:两段文本确实不同" repaired, logs = repair_annotation(article, bad_annotated) is_valid, _ = validate_annotation(article, repaired) assert is_valid, f"引号幻觉应被修复,logs: {logs}" assert repaired == article, "修复后 text segment 应与原文一致" _ok("scene9: quote hallucination in outer text repaired", repaired) # --- Scene 10: punctuation hallucination in outer text ----------------------- def test_punctuation_hallucination_in_outer_text(): """LLM 把中文句号 '。' 改成英文句点 '.',或类似标点偏差.""" article = "训练结束。队伍带回。" bad_annotated = "训练结束.队伍带回." assert article != bad_annotated repaired, logs = repair_annotation(article, bad_annotated) is_valid, _ = validate_annotation(article, repaired) assert is_valid, f"标点幻觉应被修复,logs: {logs}" assert repaired == article _ok("scene10: punctuation hallucination in outer text repaired", repaired) # --- Scene 11: missing char in outer text (LLM omission) -------------------- def test_missing_char_in_outer_text(): """LLM 漏字:text segment 比原文少一个字.""" article = "本次训练取得了圆满成功,全体官兵表现优秀。" # 漏掉 "全体" 中的 "体" 字 bad_annotated = "本次训练取得了圆满成功,全官兵表现优秀。" assert article != bad_annotated repaired, logs = repair_annotation(article, bad_annotated) is_valid, _ = validate_annotation(article, repaired) assert is_valid, f"漏字应被修复,logs: {logs}" assert repaired == article _ok("scene11: missing char in outer text repaired", repaired) # --- Scene 12: mixed errors (outer text + raw_text both deviate) ------------- def test_mixed_outer_and_inner_errors(): """同时存在两种幻觉:{{}} 外引号偏差 + {{}} 内 raw_text 写成 value.""" article = "2026年6月1日\u2014\u2014指挥员刘阳宣布:\u201c训练开始!\u201d" bad_annotated = ( "{{PDJH.JBXX.RQ||2026-06-01||2026-06-01||日期||ISO}}\u2014\u2014" # raw_text 错填为 value "指挥员刘阳宣布:\u201c训练开始!\u201d" # 中文引号被改成英文引号 ) repaired, logs = repair_annotation(article, bad_annotated) is_valid, _ = validate_annotation(article, repaired) assert is_valid, f"混合错误应被修复,logs: {logs}" # 校验:raw_text 应被重建为 '2026年6月1日' segs = parse_annotated_segments(repaired) ann = next(s for s in segs if s["type"] == "annotation") assert ann["raw_text"] == "2026年6月1日", ( f"raw_text 应被重建为 '2026年6月1日',实际:{ann['raw_text']!r}" ) _ok("scene12: mixed outer text + raw_text errors repaired", repaired) def main(): tests = [ test_already_valid, test_raw_text_wrong_as_value, test_raw_text_partial_deviation, test_body_with_escape_chars, test_no_text_segment_can_repair, test_body_field_count_insufficient, test_annotation_at_tail, test_annotation_at_head, test_quote_hallucination_in_outer_text, test_punctuation_hallucination_in_outer_text, test_missing_char_in_outer_text, test_mixed_outer_and_inner_errors, ] failed = 0 for test in tests: try: test() except AssertionError as e: failed += 1 print(f"[FAIL] {test.__name__}: {e}") except Exception as e: # noqa: BLE001 failed += 1 print(f"[ERROR] {test.__name__}: {type(e).__name__}: {e}") print() total = len(tests) print(f"result: {total - failed}/{total} passed") sys.exit(0 if failed == 0 else 1) if __name__ == "__main__": main()