Procházet zdrojové kódy

feat(langgraph): /report/parse 支持 indicators 采集指标约束注入

Xiaogang Liao před 2 dny
rodič
revize
2923992e8b

+ 21 - 8
cfc-langgraph/app/agents/report_parse_agent.py

@@ -247,6 +247,17 @@ _FLORA_GROUP_TO_KEY: dict[str, str] = {
 _FLORA_EXTRA_GROUPS: list[str] = ['有害菌属', '其它重要菌属']
 
 
+def _indicator_constraint(indicators) -> str:
+    """采集指标清单 → LLM 提示词约束块;清单为空时返回空串(保持现状)。"""
+    if not indicators:
+        return ""
+    names = "、".join(str(x) for x in indicators if x)
+    if not names:
+        return ""
+    return (f"\n\n【采集指标约束】请仅提取以下指标项,name 必须与清单一致;"
+            f"报告中缺失的项返回 null,不得臆造:{names}")
+
+
 class ReportParseAgent:
     """报告解析 Agent"""
 
@@ -366,7 +377,7 @@ class ReportParseAgent:
         if foods:
             result['foods'] = foods
 
-    async def parse(self, file_path: str) -> dict:
+    async def parse(self, file_path: str, indicators: Optional[list] = None) -> dict:
         """解析 PDF 报告,算法解析 + LLM 兜底"""
         # 1. 算法解析
         result = parse_report_pdf_with_fallback(file_path)
@@ -381,7 +392,7 @@ class ReportParseAgent:
         # 2. 如果解析不完整,LLM 兜底
         if result.get('_parse_incomplete') or not result.get('disease_risks'):
             logger.info("算法解析不完整,尝试 LLM 兜底")
-            llm_result = await self._parse_with_llm(file_path)
+            llm_result = await self._parse_with_llm(file_path, indicators)
             if llm_result:
                 # 合并 LLM 结果到算法结果上(LLM 覆盖缺失字段)
                 for key in ['disease_risks', 'nutrition', 'amino_acids',
@@ -399,7 +410,8 @@ class ReportParseAgent:
         result.pop('_parse_incomplete', None)
         return result
 
-    async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None) -> dict:
+    async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None,
+                            indicators: Optional[list] = None) -> dict:
         """通用报告 LLM 解析(不经过算法解析,直接走 LLM)"""
         try:
             from PyPDF2 import PdfReader
@@ -411,7 +423,7 @@ class ReportParseAgent:
                 ctx_str = f"\n额外上下文:{json.dumps(extra_context, ensure_ascii=False)}"
 
             template = await get_prompt("report_parse_generic") or REPORT_PARSE_GENERIC_TEMPLATE
-            prompt = template.format(report_text=text[:12000] + ctx_str)
+            prompt = template.format(report_text=text[:12000] + ctx_str) + _indicator_constraint(indicators)
 
             if self.llm_api_key:
                 import httpx
@@ -437,7 +449,7 @@ class ReportParseAgent:
             logger.error("通用LLM解析失败: %s", e)
             return {"reportType": "unknown", "error": str(e), "summary": {}, "indicators": [], "sections": []}
 
-    async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
+    async def _parse_with_llm(self, file_path: str, indicators: Optional[list] = None) -> Optional[dict]:
         """LLM 兜底解析"""
         try:
             from PyPDF2 import PdfReader
@@ -445,7 +457,7 @@ class ReportParseAgent:
             text = '\n'.join(page.extract_text() or '' for page in reader.pages)
 
             template = await get_prompt("report_parse_gut") or REPORT_PARSE_GUT_TEMPLATE
-            prompt = template.format(report_text=text[:8000])
+            prompt = template.format(report_text=text[:8000]) + _indicator_constraint(indicators)
 
             if self.llm_api_key:
                 # 调 OpenAI 兼容 API
@@ -477,7 +489,8 @@ class ReportParseAgent:
     # ================================================================
 
     async def parse_by_type(self, file_path: str, report_type: str,
-                            extra_context: Optional[dict] = None) -> dict:
+                            extra_context: Optional[dict] = None,
+                            indicators: Optional[list] = None) -> dict:
         """按报告类型路由到专用解析器。
 
         :param file_path: PDF 文件路径
@@ -500,7 +513,7 @@ class ReportParseAgent:
             return await self._parse_scanned_image(file_path)
         else:
             logger.info("未识别的报告类型 %s,走通用解析", report_type)
-            return await self.parse_generic(file_path, extra_context)
+            return await self.parse_generic(file_path, extra_context, indicators)
 
     # ---- 格式检测 ----
 

+ 4 - 2
cfc-langgraph/app/api/report_parse.py

@@ -22,6 +22,7 @@ class ParseRequest(BaseModel):
     file_path: str
     family_id: Optional[int] = None
     user_id: Optional[int] = None
+    indicators: Optional[list[str]] = None
 
 
 class ParseResponse(BaseModel):
@@ -39,7 +40,7 @@ async def parse_report(req: ParseRequest):
                 req.file_path, req.family_id, req.user_id)
     agent = get_agent()
     try:
-        result = await agent.parse(req.file_path)
+        result = await agent.parse(req.file_path, req.indicators)
         return ParseResponse(data=result)
     except Exception as e:
         logger.error("报告解析失败: %s", e, exc_info=True)
@@ -81,6 +82,7 @@ class TypedParseRequest(BaseModel):
     file_path: str
     report_type: str = "auto"
     extra_context: Optional[dict] = None
+    indicators: Optional[list[str]] = None
 
 
 class TypedParseResponse(BaseModel):
@@ -108,7 +110,7 @@ async def parse_report_typed(req: TypedParseRequest):
                 req.file_path, req.report_type)
     agent = get_agent()
     try:
-        result = await agent.parse_by_type(req.file_path, req.report_type, req.extra_context)
+        result = await agent.parse_by_type(req.file_path, req.report_type, req.extra_context, req.indicators)
         return TypedParseResponse(data=result)
     except Exception as e:
         logger.error("类型报告解析失败: %s", e, exc_info=True)

+ 24 - 1
cfc-langgraph/tests/test_report_parse.py

@@ -58,4 +58,27 @@ async def test_report_parse_invalid_json_response():
                 assert resp.status_code == 200
                 data = resp.json()
                 assert data["code"] == 500
-                assert "解析失败" in data["message"]
+                assert "解析失败" in data["message"]
+
+
+def test_indicator_constraint_contains_names():
+    from app.agents.report_parse_agent import _indicator_constraint
+    s = _indicator_constraint(["总分", "菌群健康"])
+    assert "总分" in s and "菌群健康" in s
+    assert _indicator_constraint(None) == ""
+    assert _indicator_constraint([]) == ""
+
+
+@pytest.mark.asyncio
+async def test_report_parse_accepts_indicators_field():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/report/parse", json={
+            "file_path": "/nonexistent.pdf",
+            "family_id": 1,
+            "user_id": 1,
+            "indicators": ["总分", "菌群健康"],
+        })
+        # 字段被接受(非 422 校验错误),仅文件不存在
+        assert resp.status_code == 400
+        assert "文件不存在" in resp.json()["detail"]