计划对 app/services/refractive_record_service.py(门店端视光档案·赵阳智脑分析,函数
generate_record_analysis / build_ai_comparison)做四件改动:
build_summary_messages 加 scene 参数分流,秒读走原样kb_chunks=[] 硬编码)build_ai_comparison「跟上次比」的规则拼句 → 改成 27B 生成长官要求:不得影响任何现有调 AI/27B 的功能(秒读、AI 问答、眼导航、相似病例、社区等)。
本报告只做只读调查(grep + 局部真跑 pytest),不改任何仓库文件。
四件改动风险都可控,但②③两件各自撞上代码里已经写明的「需长官另行拍板」红线注释 ——
不是技术做不到,是这两处此前的开发者已经明确判定「超出范围,需要长官点头」,现在长官既然
拍板要做,只是提醒别漏看这两条历史备注、别把它们当技术债误删。①④两件是纯增量,风险低。
| 维度 | 结论 | 一句话 |
|---|---|---|
| 维1 27B 调用方 | 🟢 | 15 处 VLLMClient() 各自建实例,无单例/无共享状态,改一处不影响其他调用方 |
| 维2 VLLMClient 共享状态 | 🟢 | 类里 0 单例痕迹(_instance/lru_cache/global 全 0 命中),纯无状态包装类 |
| 维3 build_summary_messages 调用方 | 🟢 | 仅 2 处真调:four_l_summary_service.py:226(秒读,禁动)+ refractive_record_service.py:1133(本次目标) |
| 维4 prompt citations 段 | 🟡 | 防幻觉约束句(不准凭训练知识自由发挥)跟 citations 段耦合在同一个 header 常量里,删 citations 若连带删了这句会伤秒读质量 |
| 维5 citations 消费方 | 🟢 | 门店端解析(generate_record_analysis)从不读 citations 字段;前端类型/组件也不含 citations |
| 维6 秒读 vs 视光档案 JSON 解析 | 🟢 | 两处平行独立解析函数,视光档案那份天然不摸 citations |
| 维7 KnowledgeRAGService 状态 | 🟡 | get_embedding_service() 是模块级 global 单例(embedding_service.py:125),接 RAG 会让本 scene 新增一路并发方,但已有 4 个调用方共用同一单例,非新增风险类型 |
| 维8 build_ai_comparison 调用方 | 🟡 | 单一后端调用点(refractive_records.py:281)+ 单一前端消费点,但文档原话写死"避免GET接口再叠一次27B延迟...如需改为LLM叙述需长官另行拍板" |
| 维9 聊天链路独立性 | 🟢 | h5_chat.py 跟四个目标符号交集 0 命中,对照组(直连 VLLMClient)也 0 命中——本就走 BrainGateway,物理不共享代码 |
| 维10 眼导航/相似病例/社区/编排 | 🟢 | 全部只碰 compute_summary_hint 或独立 VLLMClient()/KnowledgeRAGService(),0 处碰 build_summary_messages |
| 维11 测试基线 | 🟡 | 抽跑 4 个相关测试文件:2 failed, 54 passed(失败的是要连真 27B+真PG 的用例,属于本云机环境基线红,非本次改动引入) |
| 维12 前端 parseAiAnalysis | 🟢 | 校验字段固定 p1_diag/p2_advice/p3_warning/plan_4l/plan_drugs_plus,类型定义里完全没有 citations 字段 |
| 维13 附·历史注释 | 🟡 | refractive_record_service.py:449-452 原话:"KB A检索本次未接入...如需补做需长官另行拍板"——②正是要补做这条 |
app/services/refractive_record_service.py:1135: resp = await VLLMClient().generate_chat(
app/services/four_l_orchestrator.py:712: summary_db, VLLMClient(),
app/services/vision_ocr_service.py:118: vllm = VLLMClient()
app/services/community_service.py:30: self.vllm_client = VLLMClient()
app/services/brain_gateway.py:127: self.vllm_client = VLLMClient()
app/services/brain_gateway.py:1621: self._vllm_high_level = VLLMClient()
app/services/query_rewrite_service.py:59: result = await VLLMClient().generate_chat(
app/api/four_l_report.py:157/549/581: vllm = VLLMClient() / FourLSummaryService(db, VLLMClient(), ...)
app/api/refractive_records.py:379: data = await VLLMClient().extract(str(path), kind)
app/api/parent_ocr.py:103: data = await VLLMClient().extract(str(path), kind)
对照组:VLLMClient 全仓命中数 = 37(比 15 处实例化多,含 import/注释/异常类引用,查法有效)。
结论:每个调用方各自 VLLMClient() 新建实例,彼此不共享调用状态。
app/services/vllm_client.py 是纯异常体系 + 无状态 client 包装(httpx 每次调用建连接)。
_instance|lru_cache|^_client|global |singleton 关键字 0 命中;对照组 class VLLMClient 命中 1 次
(证明查法生效,不是语法错)。结论:该类没有跨请求共享状态,可以放心让新调用方(视光档案)
携带不同 prompt/参数,不影响其它调用方。
def build_summary_messages(
hint: SummaryHint,
kb_chunks: list[dict] | None = None,
) -> list[dict]:
真正调用 build_summary_messages(...) 只有两处:
- app/services/four_l_summary_service.py:226(秒读引擎本体,不能动)
- app/services/refractive_record_service.py:1133(本次目标)
compute_summary_hint 被 eye_nav.py:133/221、four_l_summary_service.py:213、
refractive_record_service.py:1132 调用,但 eye_nav.py/find_similar_service.py
只用 compute_summary_hint,不碰 build_summary_messages(维10 逐文件验证 0 命中)。
⚠️ 唯一未查全的点:rumor_question_screening.py:45 有 from app.services.four_l_summary_prompt import (
多行 import,raw 输出未展开括号内具体符号列表 —— 建议实施前单独 sed -n '45,55p' app/services/rumor_question_screening.py
确认它是否也调 build_summary_messages(目前证据不足以下结论,不排除有第 3 个调用方)。
KB_A_CITATIONS_SECTION_HEADER: str = (
"【KB A 引文 grounding · 严守】\n"
"- 以下是知识库 A 检索结果(来自赵阳博士权威 4L 疗法资料 + 临床案例)\n"
"- 每条 L1-L4 建议(p2_advice)必须基于这些引文 · 不准凭训练知识自由发挥\n"
"- 输出 citations 字段必填:每条 {text, source_file}\n"
"- 若 KB 检索 0 命中 · 输出 citations: [] · p2_advice 注明'暂无 KB A 出处·建议线下医生确认'\n"
)
拼装处(line 474):f"{KB_A_CITATIONS_SECTION_HEADER}\n" f"{_format_kb_chunks(kb_chunks)}\n\n"。
输出格式段(line 476/488/490/491)明确写死 "7 个顶层 key"、plan_4l 内部只有 4 个 key、
citations 是第 7 个 key。防幻觉约束句 不准凭训练知识自由发挥 就嵌在 header 常量第 3 行,
_format_kb_chunks 0 命中分支(line 326)还有一句 27B 不准基于训练知识编 L1-L4 建议。
红线:这两句防幻觉约束是给"有 KB 检索结果时约束 27B 别瞎编"用的,跟 citations 字段是否
输出是耦合在同一段文本里的。如果新 scene 要去掉 citations 输出要求,不能把整个
KB_A_CITATIONS_SECTION_HEADER 一起拿掉 —— 秒读那边(scene 默认值)必须继续拿到这段文本,
否则等于顺手削弱了秒读的防幻觉约束(超出本次范围的隐性回归)。正确做法是只在新 scene 分支
跳过"7 key → 6 key"的输出格式声明和 header 拼接,不影响默认分支的文本。
后端:four_l_summary_service.py 的 SummaryResult.citations(line 94/319)从 27B 输出 JSON 解析
(line 295 raw_citations = parsed.get("citations", []) or []);另有 _build_citations
方法(line 500-538)用 kb_chunks 重新构造、并显式注释"忽略27B输出的result.citations·因为
27B没source_url"(line 506)—— 即最终落库/推给前端的 citations 其实不是直接用 27B 吐的那份。
前端:web/app/h5/eye-nav/lib/types.ts:35 有类型声明 citations: SummaryCitation[];
four-l-detail-sheet.tsx:1180-1182 注释写明"底部'赵阳智脑论证溯源'citations块 · 长官5-27拍
删 · summary_json.citations 后端数据保留 · 仅前端不渲染"——citations 目前前端已不渲染,
只是数据留着。
结论:视光档案(generate_record_analysis)从头到尾没有解析或使用 citations 字段
(见维6),去掉它不影响任何现有下游。
refractive_record_service.py:1139-1164 的解析只提取 p1_diag/p2_advice/p3_warning/plan_4l/
plan_drugs_plus,不含 citations;result = {...} 字典(line 497-505)本身也没有 citations
键。跟 four_l_summary_service.py 那份平行但独立的解析逻辑(line 279-344,含 citations)物理
分离,互不干扰。
调用方 8 处(four_l_orchestrator.py/community_service.py/brain_gateway.py/
four_l_summary_service.py(可选注入)/four_l_report.py/vision_exams.py)。构造函数:
def __init__(self, db: AsyncSession, ollama_base_url: str = "http://localhost:11434"):
self.db = db
self.embedding_service = get_embedding_service()
get_embedding_service() 定位在 embedding_service.py:125,grep 命中 global _embedding_service
—— 是模块级全局单例。Semaphore|pool|max_connections|timeout 关键字只命中两处 httpx
timeout=300.0/timeout=120.0,没有并发信号量限流。
结论:接入 RAG(改动②)会让门店端这条新链路复用已有的全局 embedding 单例,增加一路
并发调用方,但这是"新增一个使用者",不是"引入新的资源竞争模式"——现有 4 个调用方已经在共用
同一单例,风险属于量变不是质变。真正的红线在维13:这条链路此前被显式标注"本次未接入,
需长官另行拍板"。
后端唯一调用点:app/api/refractive_records.py:281
result["ai_comparison"] = await svc.build_ai_comparison(db, org_id, rec, eyes)。
前端唯一消费点:frame4-record-view.tsx:472 getComparison(record.id, "latest"),注释写明
"复用既有getComparison()(scope参数对ai_comparison无影响,取最轻量latest)"。
现有实现(line 653-740)是纯规则拼句,函数 docstring 原话:
"叙述文本为规则化拼句(非二次LLM调用):...避免GET接口再叠一次27B延迟,也避免自由生成叙述
带来的瞎编风险。如需改为LLM自然语言叙述,需长官另行拍板。"
baseline = history[-1] 硬编码取历史最近一条(非窗口化),跟维8 前端另一套 getComparison(...,
"last6"/"all",{limit:20}) 的多窗口逻辑是两套并行机制(comparison-panel 主表格用 scope 窗口,
ai_comparison 卡片用固定"最近一条")。
结论:改动③把这条规则拼句换成 27B 生成,是直接推翻代码里已经写明的"故意不用 LLM"的设计
决定,技术上可行(单一调用点、单一消费点、改起来干净),但这不是"删代码 bug"是"翻已有拍板"——
既然长官这次亲自要求改,只是要留痕:这处改动等于覆盖了此前"避免GET接口叠加27B延迟"的顾虑,
建议把 comparison 27B 调用做成 fire-and-forget/异步预生成或加缓存,不要让 GET
/records/{id}/comparison 同步等 27B 响应,否则会引入新的接口延迟(现状 0 延迟 → 加一次
27B round-trip)。
app/api/h5_chat.py import 列表跟 four_l_summary_prompt|refractive_record_service|
build_summary_messages|knowledge_rag_service 交集 0 命中;对照组(该文件直连
VLLMClient|vllm|generate_chat)同样 0 命中——查法有效,证明 h5_chat.py 物理上不共享
这批代码,走的是 BrainGateway(line 20 from app.services.brain_gateway import BrainGateway)。
结论:本次四项改动不可能触达聊天链路。
| 文件 | 碰 build_summary_messages? | 碰 refractive_record_service/knowledge_rag_service? |
|---|---|---|
| eye_nav.py | 0(只用 compute_summary_hint) | 0 |
| find_similar_service.py | 0(只 import SummaryHint 类型) | 0 |
| community_service.py | 0 | 自建 VLLMClient()+KnowledgeRAGService(),独立实例 |
| four_l_orchestrator.py | 0(通过 four_l_summary_service 间接) | 自建 KnowledgeRAGService,独立实例 |
| four_l_report.py | 0(通过 FourLSummaryService 间接) | 自建 KnowledgeRAGService,独立实例 |
结论:四个模块全部要么完全不碰目标符号,要么用独立实例碰 VLLMClient/
KnowledgeRAGService(各自 __init__ 传参,不共享单例状态,唯一共享的全局态是维7 提到的
embedding_service),没有一条路径会因为改 build_summary_messages 签名或
refractive_record_service.py 内部逻辑而被波及。
相关测试文件清单(ls tests/** 过滤后 34 个),抽跑 4 个直接相关的:
将跑: tests/contract/test_batch5_ai_analysis_4l.py tests/contract/test_batch3_binding.py
tests/unit/test_four_l_summary_prompt.py tests/unit/test_four_l_summary_service.py
...
FAILED tests/unit/test_four_l_summary_service.py::test_success_real_27b_real_pg
FAILED tests/unit/test_four_l_summary_service.py::test_quality_check_failed_axial_trend_recede
2 failed, 54 passed, 9 warnings in 10.24s
这是改动前的基线,失败的两个用例名字都带 real_27b/real_pg,大概率是本云机环境连不上
真 27B/真 PG(项目铁律"no-mock e2e 真打"要求真连,云机可能没有可达的 27B 端点)导致,不是
本次调查引入的新问题。改动后复核必须以"54 passed 基线不倒退"为准,而不是苛求 2 个失败项也
转绿(那两项失败原因跟本次改动无关,需另查环境连通性)。
interface Parsed4LAnalysis {
p1_diag: string;
p2_advice: string;
p3_warning: string;
plan_4l: Plan4L | null;
plan_drugs_plus: string;
}
function parseAiAnalysis(text: string): Parsed4LAnalysis | null {
try {
const obj = JSON.parse(text) as Record<string, unknown>;
if (!obj || typeof obj !== "object"
|| typeof obj.p1_diag !== "string"
|| typeof obj.p2_advice !== "string"
|| typeof obj.p3_warning !== "string") { return null; }
...
} catch { return null; }
}
类型定义和校验逻辑里完全没有 citations,只强制 p1_diag/p2_advice/p3_warning 三个必填
字符串 + 可选 plan_4l/plan_drugs_plus。结论:去掉 citations 输出对前端 0 影响
(前端本来就没检查它、也没渲染它,维5 已确认)。
KB A 检索(FAISS grounding)本次未接入:build_summary_messages 显式支持
kb_chunks=[] 走老逻辑(该函数 docstring:"None 或 [] 时走老逻辑(0 KB grounding ·
向后兼容)"),接入需要 FAISSService/KnowledgeRAGService 依赖注入,超出本次
"结构化 4L 输出"范围,如需补做需长官另行拍板。
以及批 5 顶部大注释(line 968-981):"9-03 长官打回改造:AI分析不再是纯文本对话·复用报告
秒读(4L)引擎...仍不消耗秒读额度(不调 org_service.record_usage·只是复用引擎代码,不复用
额度计费)"、"6类触发重算的临床字段(视力/验光/眼轴/角膜曲率/眼压/瞳距)"。
结论:这条注释就是改动②要补的那个口子,措辞跟本次任务描述完全对得上,不是要新造能力,
是把此前显式搁置的 TODO 兑现。
scene 参数方案build_summary_messages(hint, kb_chunks=None)(app/services/four_l_summary_prompt.py:442)scene: str = "clinic_4l"(默认值必须等于秒读现有语义的名字,不能叫None/留空,否则调用方漏传时容易被误判为"新 scene"),four_l_summary_service.py:226refractive_record_service.py:1133 改成显式传build_summary_messages(hint, kb_chunks=[], scene="store_record_analysis")。scene != "clinic_4l" 时跳过 KB_A_CITATIONS_SECTION_HEADER 拼接KB_A_CITATIONS_SECTION_HEADER 常量本体或 _format_kb_chunks 的 0 命中兜底句four_l_summary_service.py:213/226 两行调用点、eye_nav.py:133/221compute_summary_hint 调用(它们根本不传 kb_chunks/不碰 build_summary_messages,compute_summary_hint 签名也动了就会波及,故本条改动build_summary_messages,不碰 compute_summary_hint)。refractive_record_service.py:454(generate_record_analysis 内 import 段)+:470-471(hint = compute_summary_hint(...) / messages = build_summary_messages(hint,
kb_chunks=[]) 这两行)。参照 four_l_orchestrator.py:704/710from app.services.knowledge_rag_service import KnowledgeRAGService +_rag = KnowledgeRAGService(summary_db))的既有接法抄一份,不要另造新的 RAG 客户端。generate_record_analysis 函数签名需要新增 rag_service: KnowledgeRAGService | None = Nonefour_l_summary_service.py:127 的可选注入写法),调用方_run_analysis_task(refractive_record_service.py:509 附近)负责实例化并传入,KnowledgeRAGService(db)——那样测试/降级路径就没法传 Nonefour_l_summary_service.py:356-358# Phase 43 v0.2 · Bug 3 · 可选注入 · None 时降级走老逻辑 就是这个模式,照抄)。get_embedding_service()(app/services/embedding_service.py:125)是全局单例,four_l_summary_service.py 走的秒读默认路径完全不受影响rag_service 注入参数,互不干扰)。refractive_record_service.py:653-740(build_ai_comparison 函数体),narrative 赋值(line 723-733)那段规则拼句替换成 27B 调用。app/api/refractive_records.py:281 不用改签名(build_ai_comparison 对外-> dict[str, Any],available/baseline_visit_at/metrics/narrative 四个 keyframe4-record-view.tsx:474-475/comparison-panel.tsx 按这四个 key 消费,record_comparison 是 GET 接口(refractive_records.py:258),four_l_summary_service.py:340 MAX_TOKENS=2000narrative 生成做成后台异步预生成+缓存(仿_run_analysis_task 模式,refractive_record_service.py:509),要么明确告知长官"这个metrics(line 686-721 的数值计算)保留规则计算不动,只替换 narrative 的生成方式——build_ai_comparison 需要新增 window: str = "latest" 参数控制history = await _load_store_visible_history(...)(line 670)取到的历史范围与baseline = history[-1](line 674)的选取逻辑——目前是"只取最近一条做双点对比",metrics/narrative 的产出形状(单点差值frame4-record-view.tsx:472 当前硬编码 getComparison(record.id, "latest")2 failed, 54 passed(4 个测试文件,10.24s)——real_27b_real_pg 失败项若环境rumor_question_screening.py:45 的 from app.services.four_l_summary_prompt
import (...) 具体导入了哪些符号,排除维3标记的未查全风险。scene 参数落地后,跑 grep -n "build_summary_messages(" app/ -r 确认全仓仍然只有grep -n "KnowledgeRAGService(" app/services/refractive_record_service.pybuild_ai_comparison 改造后,跑 tests/contract/test_batch5_ai_analysis_4l.py/records/{id}/comparison 确认响应耗时(判断③是否引入了明显延迟)。web/__tests__/h5-family-source-badge.test.tsx 及 frame4-record-view.tsxparseAiAnalysis 未因新增字段而回退到 null 分支。云机环境·非验收证据