Refine AI review false-positive rules

This commit is contained in:
Codex
2026-05-27 16:52:53 +08:00
parent 54ea338bd6
commit 16b7b927d9
2 changed files with 159 additions and 13 deletions

View File

@@ -2346,8 +2346,11 @@ def build_ai_prompt(record: dict[str, Any], pdf_context: dict[str, Any], privacy
7. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称请把完整名称放入 suggested_updates。 7. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称请把完整名称放入 suggested_updates。
8. 门急诊诊断编码只能用于 outpatient_diagnosis_code主要诊断请修正 discharge_diagnoses 中“诊断类别=主要诊断”的行,不要把门急诊诊断编码复制到 discharge_diagnoses[].疾病编码,除非出院诊断表格对应“疾病编码”单元格本身清楚显示该编码。 8. 门急诊诊断编码只能用于 outpatient_diagnosis_code主要诊断请修正 discharge_diagnoses 中“诊断类别=主要诊断”的行,不要把门急诊诊断编码复制到 discharge_diagnoses[].疾病编码,除非出院诊断表格对应“疾病编码”单元格本身清楚显示该编码。
9. PDF局部截图可能因遮挡、截屏边界或缩放只显示编码/文字前半段;如果 PDF 显示值是结构化字段值的前缀,且没有相反证据,应判为 match/ok不要写“需确认完整编码”也不要建议把结构化字段截短。 9. PDF局部截图可能因遮挡、截屏边界或缩放只显示编码/文字前半段;如果 PDF 显示值是结构化字段值的前缀,且没有相反证据,应判为 match/ok不要写“需确认完整编码”也不要建议把结构化字段截短。
10. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明 10. 入院途径、离院方式、入院病情等代码字段只核对代码本身;例如 PDF 显示“2门诊”而结构化字段为“2”就是一致不要要求复核代码与中文标签关系
11. 不要编造 PDF 中看不见的内容,不要输出置信度 11. 手术操作日期只有在日历日期确实早于入院日期时才算问题;同日或晚于入院日期均为正常,不要推测月份应改为其他月份
12. 手术及操作编码允许带 x 和 001/002/005/006 等扩展后缀如果原始内容显示“54.5100x ... 005”这类拆开的后缀应建议写入完整编码“54.5100x005”,不要要求人工确认扩展码是否有效。
13. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明。
14. 不要编造 PDF 中看不见的内容,不要输出置信度。
必须返回这个 JSON 结构: 必须返回这个 JSON 结构:
{{ {{
@@ -2560,6 +2563,128 @@ def ai_pdf_prefix_truncation_issue(value: Any) -> bool:
return ai_text_pdf_prefix_issue(value) return ai_text_pdf_prefix_issue(value)
def ai_extract_leading_code(value: Any) -> str:
text = str(value or "").strip()
text = text.replace("", "(").replace("", ")").replace("", ":")
match = re.match(r"^\s*([A-Za-z]?\d+(?:[.\-xX]\d+)*)", text)
return ai_compact_compare_value(match.group(1)) if match else ""
def ai_code_label_text(value: Any) -> bool:
text = str(value or "")
return bool(re.search(r"(代码|编码|入院途径|离院方式|入院病情|[A-Za-z_][A-Za-z0-9_]*_code)", text, flags=re.IGNORECASE))
def ai_code_label_values_match(pdf_value: Any, structured_value: Any) -> bool:
pdf_code = ai_extract_leading_code(pdf_value)
structured_code = ai_extract_leading_code(structured_value)
if not pdf_code or not structured_code or pdf_code != structured_code:
return False
pdf_text = str(pdf_value or "")
structured_text = str(structured_value or "")
return pdf_text != structured_text and (
bool(re.search(r"[(][^)]{1,12}[)]", pdf_text)) or ai_code_label_text(pdf_text + structured_text)
)
def ai_text_code_label_consistent_issue(text: Any) -> bool:
raw = str(text or "")
if not raw or "PDF" not in raw or not ai_code_label_text(raw):
return False
if re.search(r"(缺失|为空|未填|未填写|漏填)", raw):
return False
pdf_match = re.search(
r"PDF[^,;。]*?(?:显示|勾选|可见|值)?[^,;。]*?(?:为|是|=)?\s*[\"'“”‘’]?([A-Za-z]?\d+(?:[.\-xX]\d+)*)(?:[)\"'“”‘’]|[(][^)]{1,12}[)])?",
raw,
flags=re.IGNORECASE,
)
structured_match = re.search(
r"(?:结构化字段|结构化值|结构化|[A-Za-z_][A-Za-z0-9_]*_code|[A-Za-z_][A-Za-z0-9_]*字段值|字段值)[^,;。]*?(?:是否为|为|是|=)?\s*[\"'“”‘’]?([A-Za-z]?\d+(?:[.\-xX]\d+)*)",
raw,
flags=re.IGNORECASE,
)
if structured_match and not pdf_match and re.search(r"(与PDF一致|与PDF相符)", raw):
return True
if pdf_match and not structured_match and re.search(r"(与结构化一致|与PDF一致|是否与结构化一致|是否匹配|是否正确)", raw):
return True
if not pdf_match or not structured_match:
return False
return ai_compact_compare_value(pdf_match.group(1)) == ai_compact_compare_value(structured_match.group(1))
def ai_code_label_consistent_issue(value: Any) -> bool:
if isinstance(value, dict):
pdf_value = value.get("pdf_value") or value.get("pdf") or value.get("PDF值") or value.get("图片值")
structured_value = (
value.get("structured_value")
or value.get("structured")
or value.get("field_value")
or value.get("结构化值")
or value.get("结构化字段")
)
if ai_code_label_values_match(pdf_value, structured_value) and ai_code_label_text(ai_join_text(value)):
return True
return ai_text_code_label_consistent_issue(ai_join_text(value))
return ai_text_code_label_consistent_issue(value)
def ai_parse_date_token(value: str) -> date | None:
match = re.match(r"(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})", value.strip())
if not match:
return None
try:
return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
except ValueError:
return None
def ai_dates_in_text(text: str) -> list[tuple[date, int]]:
dates: list[tuple[date, int]] = []
for match in re.finditer(r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}", text):
parsed = ai_parse_date_token(match.group(0))
if parsed:
dates.append((parsed, match.start()))
return dates
def ai_operation_date_not_early_issue(value: Any) -> bool:
text = ai_join_text(value)
if not text or not re.search(r"(手术操作日期|手术日期|手术时间)", text):
return False
if not re.search(r"(早于入院|入院前|同一天|是否早于)", text):
return False
if re.search(r"PDF.{0,24}\d{4}[-/年]\d{1,2}[-/月]\d{1,2}.{0,24}结构化(?:字段|值)", text):
return False
admission_match = re.search(r"入院(?:时间|日期)?[^0-9]{0,12}(\d{4}[-/年]\d{1,2}[-/月]\d{1,2})", text)
if not admission_match:
return False
admission_date = ai_parse_date_token(admission_match.group(1))
if not admission_date:
return False
operation_dates = [
item_date
for item_date, position in ai_dates_in_text(text)
if position < admission_match.start()
]
if not operation_dates:
operation_dates = [
item_date
for item_date, position in ai_dates_in_text(text)
if position != admission_match.start(1)
]
if not operation_dates:
return False
return all(item_date >= admission_date for item_date in operation_dates)
def ai_false_positive_issue(value: Any) -> bool:
return (
ai_pdf_prefix_truncation_issue(value)
or ai_code_label_consistent_issue(value)
or ai_operation_date_not_early_issue(value)
)
def ai_has_nonblank_suggested_updates(parsed: dict[str, Any]) -> bool: def ai_has_nonblank_suggested_updates(parsed: dict[str, Any]) -> bool:
suggested_updates = parsed.get("suggested_updates") suggested_updates = parsed.get("suggested_updates")
if not isinstance(suggested_updates, list): if not isinstance(suggested_updates, list):
@@ -2579,16 +2704,20 @@ def ai_problem_evidence(parsed: dict[str, Any]) -> list[dict[str, Any]]:
] ]
def ai_only_pdf_prefix_truncation(parsed: dict[str, Any]) -> bool: def ai_only_false_positive(parsed: dict[str, Any]) -> bool:
if ai_has_nonblank_suggested_updates(parsed): if ai_has_nonblank_suggested_updates(parsed):
return False return False
issues = ai_remaining_issues(parsed) issues = ai_remaining_issues(parsed)
if issues: if issues:
return all(ai_pdf_prefix_truncation_issue(issue) for issue in issues) return all(ai_false_positive_issue(issue) for issue in issues)
evidence = ai_problem_evidence(parsed) evidence = ai_problem_evidence(parsed)
if evidence: if evidence:
return all(ai_pdf_prefix_truncation_issue(item) for item in evidence) return all(ai_false_positive_issue(item) for item in evidence)
return ai_pdf_prefix_truncation_issue(parsed.get("summary")) or ai_pdf_prefix_truncation_issue(parsed.get("decision")) return ai_false_positive_issue(parsed.get("summary")) or ai_false_positive_issue(parsed.get("decision"))
def ai_only_pdf_prefix_truncation(parsed: dict[str, Any]) -> bool:
return ai_only_false_positive(parsed)
def normalize_ai_parsed(parsed: dict[str, Any]) -> dict[str, Any]: def normalize_ai_parsed(parsed: dict[str, Any]) -> dict[str, Any]:
@@ -2596,9 +2725,9 @@ def normalize_ai_parsed(parsed: dict[str, Any]) -> dict[str, Any]:
remaining = normalized.get("remaining_issues") remaining = normalized.get("remaining_issues")
if isinstance(remaining, list): if isinstance(remaining, list):
normalized["remaining_issues"] = [ normalized["remaining_issues"] = [
item for item in remaining if ai_needs_review_text(item) and not ai_pdf_prefix_truncation_issue(item) item for item in remaining if ai_needs_review_text(item) and not ai_false_positive_issue(item)
] ]
elif remaining and ai_needs_review_text(remaining) and not ai_pdf_prefix_truncation_issue(remaining): elif remaining and ai_needs_review_text(remaining) and not ai_false_positive_issue(remaining):
normalized["remaining_issues"] = [remaining] normalized["remaining_issues"] = [remaining]
else: else:
normalized["remaining_issues"] = [] normalized["remaining_issues"] = []
@@ -2622,7 +2751,7 @@ def ai_has_confirmed_problem(parsed: dict[str, Any]) -> bool:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
result = str(item.get("result") or "").strip().lower() result = str(item.get("result") or "").strip().lower()
if result in {"mismatch", "uncertain", "missing", "problem"} and not ai_pdf_prefix_truncation_issue(item): if result in {"mismatch", "uncertain", "missing", "problem"} and not ai_false_positive_issue(item):
return True return True
if resolution in {"false_positive", "ok", "no_issue", "误报", "无问题"}: if resolution in {"false_positive", "ok", "no_issue", "误报", "无问题"}:
@@ -2651,7 +2780,7 @@ def ai_remaining_issues(parsed: dict[str, Any]) -> list[Any]:
def ai_has_unresolved_problem(parsed: dict[str, Any]) -> bool: def ai_has_unresolved_problem(parsed: dict[str, Any]) -> bool:
if ai_only_pdf_prefix_truncation(parsed): if ai_only_false_positive(parsed):
return False return False
if ai_remaining_issues(parsed): if ai_remaining_issues(parsed):
return True return True
@@ -2687,20 +2816,26 @@ def ai_classification(parsed: dict[str, Any]) -> str:
resolution = str(parsed.get("issue_resolution") or "").strip().lower() resolution = str(parsed.get("issue_resolution") or "").strip().lower()
ok_values = {"ok", "pass", "passed", "no_issue", "no issue", "无问题", "通过", "已通过"} ok_values = {"ok", "pass", "passed", "no_issue", "no issue", "无问题", "通过", "已通过"}
problem_values = {"problem", "not_ok", "not ok", "confirm", "不ok", "不通过", "需确认", "待确认", "需复核"} problem_values = {"problem", "not_ok", "not ok", "confirm", "不ok", "不通过", "需确认", "待确认", "需复核"}
if ai_only_pdf_prefix_truncation(parsed): if ai_only_false_positive(parsed):
return AI_OK_STATUS return AI_OK_STATUS
if classification in ok_values or decision in ok_values or resolution in {"false_positive", "ok", "no_issue", "误报", "无问题", "通过"}: if classification in ok_values or decision in ok_values or resolution in {"false_positive", "ok", "no_issue", "误报", "无问题", "通过"}:
return AI_OK_STATUS return AI_OK_STATUS
if classification in problem_values or decision in problem_values or resolution in {"confirmed_problem", "uncertain", "problem", "待确认", "已证实"}: if classification in problem_values or decision in problem_values or resolution in {"confirmed_problem", "uncertain", "problem", "待确认", "已证实"}:
if not ai_remaining_issues(parsed) and ai_has_nonblank_suggested_updates(parsed): if not ai_remaining_issues(parsed) and ai_has_nonblank_suggested_updates(parsed):
return AI_OK_STATUS return AI_OK_STATUS
if (
not ai_remaining_issues(parsed)
and not any(not ai_false_positive_issue(item) for item in ai_problem_evidence(parsed))
and not ai_has_unresolved_problem(parsed)
):
return AI_OK_STATUS
return AI_PROBLEM_STATUS return AI_PROBLEM_STATUS
if ai_remaining_issues(parsed) or ai_has_unresolved_problem(parsed): if ai_remaining_issues(parsed) or ai_has_unresolved_problem(parsed):
return AI_PROBLEM_STATUS return AI_PROBLEM_STATUS
suggested_updates = parsed.get("suggested_updates") suggested_updates = parsed.get("suggested_updates")
if isinstance(suggested_updates, list) and suggested_updates: if isinstance(suggested_updates, list) and suggested_updates:
return AI_OK_STATUS return AI_OK_STATUS
if any(not ai_pdf_prefix_truncation_issue(item) for item in ai_problem_evidence(parsed)): if any(not ai_false_positive_issue(item) for item in ai_problem_evidence(parsed)):
return AI_PROBLEM_STATUS return AI_PROBLEM_STATUS
return AI_OK_STATUS return AI_OK_STATUS
@@ -2724,6 +2859,15 @@ def blank_ai_value(value: Any) -> bool:
return any(marker in text for marker in ("空白", "未填写", "无内容", "可见但为空", "编码栏可见但为空", "null")) return any(marker in text for marker in ("空白", "未填写", "无内容", "可见但为空", "编码栏可见但为空", "null"))
def normalize_operation_code(value: Any) -> str:
text = str(value or "").strip()
text = re.sub(r"\s+", " ", text)
match = re.match(r"^([A-Za-z]?\d{1,3}\.\d{1,4}x?)\s+(\d{3})$", text, flags=re.IGNORECASE)
if match:
return f"{match.group(1)}{match.group(2)}"
return text
def suggested_update_value(item: dict[str, Any]) -> Any: def suggested_update_value(item: dict[str, Any]) -> Any:
for key in ("value", "new", "new_value", "pdf_value", "suggested_value"): for key in ("value", "new", "new_value", "pdf_value", "suggested_value"):
if key in item: if key in item:
@@ -2829,6 +2973,8 @@ def ai_suggested_updates(result: dict[str, Any], before: dict[str, Any]) -> tupl
compare_old = rows[index].get(key) compare_old = rows[index].get(key)
if ai_outpatient_code_leak(path, value, before, compare_old): if ai_outpatient_code_leak(path, value, before, compare_old):
continue continue
if field == "operations" and key == "手术操作编码":
value = normalize_operation_code(value)
if ai_pdf_value_is_structured_prefix(value, compare_old): if ai_pdf_value_is_structured_prefix(value, compare_old):
continue continue
if comparable(compare_old) == comparable(value): if comparable(compare_old) == comparable(value):

View File

@@ -581,7 +581,7 @@ function baseReviewNotes(record) {
function aiAttentionNotes(record) { function aiAttentionNotes(record) {
const notes = []; const notes = [];
const latestAiLog = (record.review_logs || []).find((log) => Boolean(log.ai_result)); const latestAiLog = [...(record.review_logs || [])].reverse().find((log) => Boolean(log.ai_result));
const parsed = latestAiLog?.ai_result?.parsed; const parsed = latestAiLog?.ai_result?.parsed;
if (!parsed) return notes; if (!parsed) return notes;
if (Array.isArray(parsed.remaining_issues)) { if (Array.isArray(parsed.remaining_issues)) {