| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292 |
- """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()
|