diff --git a/患者首页处理/数据可视化网页端/app/main.py b/患者首页处理/数据可视化网页端/app/main.py index facb90a..5c30e68 100644 --- a/患者首页处理/数据可视化网页端/app/main.py +++ b/患者首页处理/数据可视化网页端/app/main.py @@ -2346,8 +2346,11 @@ def build_ai_prompt(record: dict[str, Any], pdf_context: dict[str, Any], privacy 7. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称,请把完整名称放入 suggested_updates。 8. 门急诊诊断编码只能用于 outpatient_diagnosis_code;主要诊断请修正 discharge_diagnoses 中“诊断类别=主要诊断”的行,不要把门急诊诊断编码复制到 discharge_diagnoses[].疾病编码,除非出院诊断表格对应“疾病编码”单元格本身清楚显示该编码。 9. PDF局部截图可能因遮挡、截屏边界或缩放只显示编码/文字前半段;如果 PDF 显示值是结构化字段值的前缀,且没有相反证据,应判为 match/ok,不要写“需确认完整编码”,也不要建议把结构化字段截短。 -10. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates,不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明。 -11. 不要编造 PDF 中看不见的内容,不要输出置信度。 +10. 入院途径、离院方式、入院病情等代码字段只核对代码本身;例如 PDF 显示“2(门诊)”而结构化字段为“2”就是一致,不要要求复核代码与中文标签关系。 +11. 手术操作日期只有在日历日期确实早于入院日期时才算问题;同日或晚于入院日期均为正常,不要推测月份应改为其他月份。 +12. 手术及操作编码允许带 x 和 001/002/005/006 等扩展后缀;如果原始内容显示“54.5100x ... 005”这类拆开的后缀,应建议写入完整编码“54.5100x005”,不要要求人工确认扩展码是否有效。 +13. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates,不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明。 +14. 不要编造 PDF 中看不见的内容,不要输出置信度。 必须返回这个 JSON 结构: {{ @@ -2560,6 +2563,128 @@ def ai_pdf_prefix_truncation_issue(value: Any) -> bool: 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: suggested_updates = parsed.get("suggested_updates") 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): return False issues = ai_remaining_issues(parsed) 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) if evidence: - return all(ai_pdf_prefix_truncation_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 all(ai_false_positive_issue(item) for item in evidence) + 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]: @@ -2596,9 +2725,9 @@ def normalize_ai_parsed(parsed: dict[str, Any]) -> dict[str, Any]: remaining = normalized.get("remaining_issues") if isinstance(remaining, list): 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] else: normalized["remaining_issues"] = [] @@ -2622,7 +2751,7 @@ def ai_has_confirmed_problem(parsed: dict[str, Any]) -> bool: if not isinstance(item, dict): continue 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 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: - if ai_only_pdf_prefix_truncation(parsed): + if ai_only_false_positive(parsed): return False if ai_remaining_issues(parsed): return True @@ -2687,20 +2816,26 @@ def ai_classification(parsed: dict[str, Any]) -> str: resolution = str(parsed.get("issue_resolution") or "").strip().lower() ok_values = {"ok", "pass", "passed", "no_issue", "no issue", "无问题", "通过", "已通过"} 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 if classification in ok_values or decision in ok_values or resolution in {"false_positive", "ok", "no_issue", "误报", "无问题", "通过"}: return AI_OK_STATUS 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): 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 if ai_remaining_issues(parsed) or ai_has_unresolved_problem(parsed): return AI_PROBLEM_STATUS suggested_updates = parsed.get("suggested_updates") if isinstance(suggested_updates, list) and suggested_updates: 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_OK_STATUS @@ -2724,6 +2859,15 @@ def blank_ai_value(value: Any) -> bool: 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: for key in ("value", "new", "new_value", "pdf_value", "suggested_value"): 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) if ai_outpatient_code_leak(path, value, before, compare_old): continue + if field == "operations" and key == "手术操作编码": + value = normalize_operation_code(value) if ai_pdf_value_is_structured_prefix(value, compare_old): continue if comparable(compare_old) == comparable(value): diff --git a/患者首页处理/数据可视化网页端/app/static/app.js b/患者首页处理/数据可视化网页端/app/static/app.js index 513901e..941c825 100644 --- a/患者首页处理/数据可视化网页端/app/static/app.js +++ b/患者首页处理/数据可视化网页端/app/static/app.js @@ -581,7 +581,7 @@ function baseReviewNotes(record) { function aiAttentionNotes(record) { 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; if (!parsed) return notes; if (Array.isArray(parsed.remaining_issues)) {