wangxi 1 неделя назад
Родитель
Сommit
d7da27179c

+ 30 - 3
.env.example

@@ -1,9 +1,36 @@
+# neo4j配置信息
 NEO4J_URI=http://121.43.55.7:7474
 NEO4J_URI=http://121.43.55.7:7474
 NEO4J_USER=neo4j
 NEO4J_USER=neo4j
-NEO4J_PASSWORD=change-me
+NEO4J_PASSWORD=skyversation
 NEO4J_DATABASE=neo4j
 NEO4J_DATABASE=neo4j
-DEEPSEEK_API_KEY=sk-xxxx
+
+# deepseek配置信息
+DEEPSEEK_API_KEY=sk-8a1d2d5b63f945c1a11c2760de81da61
 DEEPSEEK_BASE_URL=https://api.deepseek.com
 DEEPSEEK_BASE_URL=https://api.deepseek.com
-DEEPSEEK_MODEL=deepseek-v4-flash
+DEEPSEEK_MODEL=deepseek-v4-pro
+STEP1_ANALYSIS_MAX_WORKERS=3
+
+# redis配置信息
 REDIS_URL=redis://127.0.0.1:6379/0
 REDIS_URL=redis://127.0.0.1:6379/0
 REDIS_CONTEXT_TTL_SECONDS=1296000
 REDIS_CONTEXT_TTL_SECONDS=1296000
+
+# DMS配置信息
+DMS_BASE_URL=http://121.43.55.7:2101/
+DMS_USERNAME=user_wangxi
+DMS_PASSWORD=Wx@123456
+
+# Step2: cosine similarity must be strictly greater than this value
+STEP2_RELATION_SIMILARITY_THRESHOLD=0.8
+
+# Web/API 服务监听地址与端口;命令行参数可覆盖
+SERVICE_HOST=0.0.0.0
+SERVICE_PORT=8000
+
+# 模板及关系目录;相对路径基于项目根目录,支持绝对路径
+METADATA_TEMPLATE_DIR=data/templates/metadata
+RELATION_DIR=data/templates
+
+# 本地嵌入模型下载与运行配置
+EMBEDDING_MODEL_DIR=models/Qwen3-Embedding-0.6B
+EMBEDDING_MODEL_REPO=Qwen/Qwen3-Embedding-0.6B
+EMBEDDING_MODEL_SOURCE=modelscope

+ 28 - 1
README.md

@@ -248,7 +248,7 @@ uv run python scripts/ask.py
 生产问答在元图谱 `meta.schema_version == 2` 时走以下分支:
 生产问答在元图谱 `meta.schema_version == 2` 时走以下分支:
 
 
 ```text
 ```text
-问题理解与本轮元图谱快照 → 选择相关数据评估 → JSON 查询计
+问题理解与本轮元图谱快照 → 选择相关数据评估 → 简单查询直接构造 / 完整模型规
 → 计划确认 → Schema 校验与只读查询编译 → Neo4j 查询 → 可溯源回答
 → 计划确认 → Schema 校验与只读查询编译 → Neo4j 查询 → 可溯源回答
 ```
 ```
 
 
@@ -269,6 +269,33 @@ uv run python scripts/ask.py
 | `api_log/` | 请求、执行结果和错误日志 |
 | `api_log/` | 请求、执行结果和错误日志 |
 | `reference/`、`data/test_data/` | 原始参考资料与测试数据 |
 | `reference/`、`data/test_data/` | 原始参考资料与测试数据 |
 
 
+### 问答速度优化
+
+生产分支对可完整表达为单数据集的计数、属性查询和简单列表,在问题理解后直接构造计划,
+减少一次规划模型调用;复杂条件、跨关系或用户修改计划时回退完整规划。人工确认与实际查询保留。
+理解和规划仅输出必要的紧凑 JSON,省略空字段、默认值和重复字段,保留全部业务条件。
+
+`QA_FAST_PLAN_ENABLED` 与 `QA_COMPACT_INTERMEDIATE_ENABLED` 默认均为 true,
+可分别在环境配置中设为 false 关闭;改代码/配置后重启服务即可,无需更新图谱。
+请求日志新增阶段耗时、模型流调用记录和快速路径命中原因。说明与验证入口见
+[Step3 速度优化](docs/step3-qa-agent.md)。
+
+### 确认答案后缓存
+
+答案下的“答案正确,加入缓存”用于人工确认;保存后可“撤销缓存”。
+查询计划的确认和自动确认不会保存答案缓存。重复提问精确命中后显示“已复用你确认的历史答案”,
+直接复用答案和来源。永久保存(不设置TTL),数据版本发布成功后删除全部旧数据版本缓存,按会话、上下文、数据/Schema版本和模型/策略隔离;
+新建会话或改变上下文可能不命中,相似问题仍走正常流程。
+
+错误、截断、待澄清、修改过条件及明确依赖历史指代/相对时间的回答不提供缓存按钮。
+关闭“历史复用”可绕过读取。`QA_ANSWER_CACHE_ENABLED`默认true;
+`QA_ANSWER_CACHE_TTL_SECONDS`已停用,即使旧环境仍配置该值也不设置过期时间;
+
+问答缓存清理只操作独立索引,不影响Redis会话上下文的过期策略。服务启动时将尚存的当前版本旧TTL缓存转为永久;已过期消失的记录无法恢复。发布完成后立即清理旧数据版本;Redis故障记录在更新报告的`answer_cache_cleanup`,服务启动及运行期间每60秒重试。原子版本校验阻止旧回答在版本切换后重新入库;用户仍可主动撤销缓存。
+
+修改提示词/权限策略时递增`QA_ANSWER_CACHE_POLICY_VERSION`(默认1),重启后生效。
+详细边界与接口见[Step3文档](docs/step3-qa-agent.md)、[API说明](docs/api-reference.md)。
+
 ### 独立构图与历史工具
 ### 独立构图与历史工具
 
 
 日常维护使用上面的统一更新入口。`scripts/build_graph.py --check-only` 是独立 Step2 预检:读取 `data/production/`、metadata 模板和 relation;历史默认主键文件 `data/graph_keys.json` 已移除,使用该入口须通过 `--keys <配置路径>` 提供主键配置,**不会跟随统一发布清单选择生产目录**。完整独立构图也不负责切换统一发布清单,不应与统一更新流程混用。
 日常维护使用上面的统一更新入口。`scripts/build_graph.py --check-only` 是独立 Step2 预检:读取 `data/production/`、metadata 模板和 relation;历史默认主键文件 `data/graph_keys.json` 已移除,使用该入口须通过 `--keys <配置路径>` 提供主键配置,**不会跟随统一发布清单选择生产目录**。完整独立构图也不负责切换统一发布清单,不应与统一更新流程混用。

+ 21 - 0
docs/api-reference.md

@@ -43,3 +43,24 @@ SSE 使用 POST,可参考 [html/index.html](../html/index.html) 中的 `askStr
 ## 数据质量
 ## 数据质量
 
 
 接口 `GET /api/data-quality` 返回总数及各文件计数;`?details=true&data_version=<版本>` 返回完整问题记录,版本变化返回409。明细沿用现有服务访问边界,能访问此接口的用户可看到问题记录的全部字段。
 接口 `GET /api/data-quality` 返回总数及各文件计数;`?details=true&data_version=<版本>` 返回完整问题记录,版本变化返回409。明细沿用现有服务访问边界,能访问此接口的用户可看到问题记录的全部字段。
+
+## 人工确认答案缓存
+
+`POST /threads/{thread_id}/answer-cache`:确认该答案正确并保存缓存。
+`DELETE /threads/{thread_id}/answer-cache`:撤销对应缓存(需entry_id)。
+
+请求仅允许:
+`{"answer_id":"答案ID","checkpoint_id":"该答案的checkpoint ID","entry_id":null}`。
+POST成功返回status、entry_id、approved_at;DELETE用该entry_id撤销。
+接口从服务端checkpoint读取原答案,不接收答案、问题或查询条件正文。
+记录失效、未完成、不符合缓存规则或版本变化返回409;缓存不可用返回503。
+额外字段或缺失标识返回422。
+
+/ask、/ask/stream的done和resume完整响应新增cache_review:
+eligible、reason、answer_id、checkpoint_id、cached、hit、entry_id、approved_at。
+eligible=true时前端展示“答案正确,加入缓存”,成功后可撤销。
+cached/hit=true显示历史答案来源。人工计划确认和auto_confirm不等于缓存确认。
+
+索引仅限同一thread_id、精确问题和上下文,包含数据/Schema/模型/策略版本,永久保存,数据版本发布成功后物理删除全部旧数据版本缓存。
+关闭reuse_check绕过缓存读取。缓存命中直接返回已确认答案及来源,不调用模型/Neo4j。
+目前无真实鉴权,不能据此宣称具有用户身份认证或允许跨用户共享缓存。

+ 89 - 0
docs/step3-qa-agent.md

@@ -60,3 +60,92 @@ uv run python scripts/test_step2_production.py
 hash和字段校验均通过。没有发送真实业务问题或评估内容到外部模型,尚不能据此
 hash和字段校验均通过。没有发送真实业务问题或评估内容到外部模型,尚不能据此
 宣称真实问答准确率提高;需在查询工具适配后执行业务问题集验收。
 宣称真实问答准确率提高;需在查询工具适配后执行业务问题集验收。
 
 
+
+## 简单问题快速规划与中间输出(2026-09-07)
+
+生产 Schema v2 在一次问题理解中同时提取可选的 `simple_query`。仅支持单数据集的
+节点去重计数、属性查询和明细列表,最多6个明确文本等于/包含的 AND 条件。
+候选必须声明完整、与原始 data_needs 单数据集/无关系选择一致,筛选值必须来自本轮问题,
+已抽取的实体及筛选槽位不能遗漏,字段和结构必须通过现有只读编译器校验。
+计数、关系或字段口径不明确时应返回 null。模型对完整性的判断仍需真实问题验收。
+
+命中时程序构造计划,省去单独规划模型调用;仍经过中文计划确认、版本检查、
+实际 Neo4j 查询、空结果候选及溯源回答。跨关系、时间/数值条件、分组排序、复杂表达、
+历史指代、未知字段、缺失候选、修改反馈都回退完整规划。每次理解重置候选,旧会话不继承。
+
+理解与完整规划均要求紧凑 JSON,不输出额外分析文字;省略空数组/空槽位、默认 limit=50、
+重复 fields/depends。所有实际筛选、关联、分组、排序、返回字段和统计口径必须保留。
+简单计数候选最小结构为:
+`{"kind":"count","dataset":"人员信息","complete":true}`。
+最终答案仍按原有规则生成,不对 JSON 使用可能导致截断的硬性 token 上限。
+
+可选环境变量(未设置时默认 true,支持 true/false/1/0):
+
+| 变量 | 作用 |
+|---|---|
+| `QA_FAST_PLAN_ENABLED` | 单数据集快速规划 |
+| `QA_COMPACT_INTERMEDIATE_ENABLED` | 理解与完整规划的紧凑输出 |
+
+两个都设为 false 可恢复原理解/规划提示词及两次调用路径。修改配置或代码后重启服务,
+无需重新取数或构图。
+
+`api_log` 中 state.trace 的 understand/production_plan 新增 duration_ms;
+production_plan.mode 为 simple 或 llm,reason 说明命中或回退原因,validation_attempts
+记录完整计划结构校验次数。llm_calls 每项记录一个 SDK 流的耗时、首次 chunk/文本等待、
+输入/输出字符数及可用的 usage。JSON 解析重试计为不同流;SDK 内部网络重试只能计入耗时,
+不能据此声称记录了精确网络请求次数。usage 缺失为 null,不把字符数当 token 数。
+耗时不包含人工等待;节点耗时减去流耗时包含上下文、SDK创建等本地处理。
+
+离线回归:
+`uv run --frozen python -X utf8 -m unittest scripts.test_simple_plan scripts.test_qa_question_context scripts.test_value_clarification scripts.test_plan_presentation scripts.test_runtime_workflow -q`。
+
+真实测试脚本 `scripts/benchmark_qa_planning.py` 交替关闭/开启两个优化,记录理解和规划耗时;
+它会调用真实配置的外部模型,须取得授权后运行。加 `--execute` 执行受控只读图谱查询,
+仅保存结果行数、截断标记及按字段/操作归一的结果哈希,不保存原始查询行。
+输出默认在 `.runtime/qa/planning-benchmark.json`。哈希比较需确认同一 build_id、
+同一口径且结果未截断;随机模型输出和无排序明细可能不同。单次样本不能代表 P95。
+
+## 用户确认的独立问答缓存(2026-09-07)
+
+网页版完成生产图谱回答后,在答案下显示“答案正确,加入缓存”。只有点击此按钮,
+服务端才把该答案加入独立 Redis 索引;查询计划的“确认”或自动确认不会写入缓存。
+保存成功后可“撤销缓存”,缓存命中显示“已复用你确认的历史答案”。
+
+索引使用 `ka:qa:approved:v1:` 前缀的 Redis String,与上下文 checkpoint 分离。
+键包含问题文本哈希、thread_id 隔离范围、最近对话摘要哈希、数据版本、Schema版本、
+模型名及缓存策略版本。只去除问题两端空白,不去除否定、数字、标点或实体内部空格;
+不做相似度直接复用。新提问先作一次有超时限制的 GET;未命中继续原问答流程。
+同一问题连续重复时沿用原问题的上下文签名;中途问过其他问题会重新计算签名,
+因此可能不命中。带明确历史指代或相对时间的问题保守地不缓存。
+
+命中后直接复用用户确认的答案、查询计划和来源,跳过模型及图谱查询,
+但仍追加本轮历史并保存新的 checkpoint;关闭页面“历史复用”可强制正常问答。
+目前API没有真实鉴权,缓存仅按会话隔离,清空/新建会话不会共享已有缓存。
+未来接入鉴权后须把实际用户/租户/权限版本纳入键,不能使用当前固定 user_id=api 共享。
+
+仅接收答案ID及checkpoint ID,不接收浏览器提交的答案正文。服务端读取该会话不可变
+checkpoint并校验完成状态,所以旧答案按钮也不会误确认后来的另一条回答。
+过期checkpoint无法确认。错误、闲聊、不支持、截断/缺失结果、待澄清、
+修改过计划/替换过候选、依赖上下文/相对时间及超过256KB的记录不入缓存。
+保存查询结果与来源仅用于复用已确认内容,不把评估报告当作事实。
+
+缓存永久保存(不设置TTL),数据版本发布成功后删除全部旧数据版本缓存。写入通过Redis原子脚本核对当前数据版本,不设置过期时间。数据或Schema更新、模型名、
+策略版本变化均不再命中旧键。数据版本发布成功后,仅扫描独立问答索引并删除全部非当前数据版本记录,不影响会话上下文及其他Redis数据。清理失败记录警告,服务启动及运行期间每60秒重试;启动时将尚存的当前版本旧TTL记录转为永久。
+撤销使用比较后删除,避免并发时删除另一条新确认记录。
+缓存读超时/损坏时回退正常问答;写入/撤销失败会明确提示,不显示成功。
+底层Redis整体不可用仍会影响原有checkpoint持久化,不承诺整个服务脱离Redis运行。
+
+| 配置 | 默认 | 用途 |
+|---|---|---|
+| `QA_ANSWER_CACHE_ENABLED` | true | 独立答案缓存及确认入口开关 |
+| `QA_ANSWER_CACHE_TTL_SECONDS` | 已停用 | 旧配置被忽略;问答缓存永久保存 |
+| `QA_ANSWER_CACHE_POLICY_VERSION` | 1 | 提示词/权限/回答策略变化时主动递增以失效旧缓存 |
+
+修改代码或配置后重启服务,无需取数或构图。现有缓存不会自动认定旧上下文中的答案正确,
+历史答案必须由用户通过对应按钮确认。
+
+主要实现:`src/step4_web/answer_cache.py`、`api.py`、
+`html/answer_cache.js`、`html/answer_cache.css`;根页iframe版本同步更新。
+离线验证:
+`uv run --frozen python -X utf8 -m unittest scripts.test_answer_cache -q`;
+页面:`node scripts/test_answer_cache_ui.cjs`(需Playwright和Edge)。

+ 8 - 0
docs/技术方案.md

@@ -668,3 +668,11 @@ knowledge_agent/
 ### 空结果候选多选(2026-09-07)
 ### 空结果候选多选(2026-09-07)
 
 
 clarify_value 支持一个或多个候选。前端候选按钮可切换选中状态,“都不是”与实际候选互斥;接口透传确认类型,普通计划确认仍为单选。后端接受逗号、中文逗号、顿号、分号分隔的序号/完整值及 JSON 字符串数组,严格解析且不做模糊代选。同一查询条件选择多个真实值时编译为 `in`,不同条件分别替换并继续使用 AND 组合,未选择的筛选和关系保持不变。
 clarify_value 支持一个或多个候选。前端候选按钮可切换选中状态,“都不是”与实际候选互斥;接口透传确认类型,普通计划确认仍为单选。后端接受逗号、中文逗号、顿号、分号分隔的序号/完整值及 JSON 字符串数组,严格解析且不做模糊代选。同一查询条件选择多个真实值时编译为 `in`,不同条件分别替换并继续使用 AND 组合,未选择的筛选和关系保持不变。
+
+### 查询延迟优化(2026-09-07)
+
+生产 Schema v2 问题理解增加可选 simple_query:单数据集计数/属性/列表的完整候选通过字段、原词、槽位覆盖和只读编译校验后,确定性构造计划;复杂/不完整候选及修改反馈继续完整规划。人工确认、版本隔离、查询和候选澄清保持原流程。理解与完整规划改为紧凑 JSON,省略空数组、默认值及重复字段,禁止遗漏条件。两个优化由 QA_FAST_PLAN_ENABLED / QA_COMPACT_INTERMEDIATE_ENABLED 独立控制,默认 true。trace 保存节点耗时、模型流次数、首次输出及字符/可用 token 统计;尚未取得真实性能结果。详见 [Step3](step3-qa-agent.md)。
+
+### 人工确认问答缓存(2026-09-07)
+
+新增独立Redis索引ka:qa:approved:v1,仅在前端点击“答案正确,加入缓存”后保存;确认查询计划不等于确认答案。按精确问题、会话、上下文、数据/Schema/模型/策略版本匹配,永久保存,数据版本发布成功后物理删除全部旧数据版本缓存。缓存命中跳过模型和图谱,保留来源并追加会话。用户可撤销;超时未命中回原流程。checkpoint和答案ID绑定,拒绝客户端答案正文;错误、截断、未完成、改过条件/候选及明确上下文/相对时间问题不缓存。现阶段不跨会话共享、不按语义相似度直接复用;详见Step3及API说明。

+ 6 - 0
html/answer_cache.css

@@ -0,0 +1,6 @@
+.answer-cache-actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-top:12px; padding-top:10px; border-top:1px solid #e2e8f0; font-size:12px; }
+.answer-cache-status { color:#64748b; overflow-wrap:anywhere; }
+.answer-cache-button { cursor:pointer; border:1px solid #94a3b8; border-radius:7px; padding:6px 10px; background:#fff; color:#334155; font:inherit; }
+.answer-cache-button:hover { background:#f1f5f9; }
+.answer-cache-button:disabled { cursor:wait; opacity:.6; }
+.answer-cache-button:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }

+ 56 - 0
html/answer_cache.js

@@ -0,0 +1,56 @@
+/* Explicit, per-answer cache approval. No background approval requests. */
+(function () {
+  function attach(bubble, payload, threadId, baseUrl) {
+    const review = payload.cache_review;
+    if (!review) return;
+    bubble.querySelector('.answer-cache-actions')?.remove();
+    const bar = document.createElement('div');
+    bar.className = 'answer-cache-actions';
+    const status = document.createElement('span');
+    status.className = 'answer-cache-status';
+    status.setAttribute('role', 'status');
+    status.setAttribute('aria-live', 'polite');
+    bar.appendChild(status);
+    bubble.appendChild(bar);
+    if (!review.eligible && !review.cached) {
+      status.textContent = review.reason ? '暂不缓存:' + review.reason : '';
+      return;
+    }
+    let cached = Boolean(review.cached);
+    let entryId = review.entry_id;
+    const button = document.createElement('button');
+    button.type = 'button';
+    button.className = 'answer-cache-button';
+    const label = () => {
+      button.textContent = cached ? '撤销缓存' : '答案正确,加入缓存';
+      button.setAttribute('aria-label', button.textContent);
+    };
+    status.textContent = review.hit ? '已复用你确认的历史答案' : '确认答案正确后,可缓存供本会话重复提问使用';
+    label();
+    button.addEventListener('click', async () => {
+      if (button.disabled) return;
+      button.disabled = true;
+      status.textContent = cached ? '正在撤销…' : '正在加入缓存…';
+      try {
+        const response = await fetch(baseUrl + '/threads/' + encodeURIComponent(threadId) + '/answer-cache', {
+          method: cached ? 'DELETE' : 'POST',
+          headers: {'Content-Type': 'application/json'},
+          body: JSON.stringify({answer_id: review.answer_id, checkpoint_id: review.checkpoint_id,
+                                entry_id: entryId || null})
+        });
+        const result = await response.json();
+        if (!response.ok || result.status !== 'ok') throw new Error(result.detail || result.message || '操作失败,请重试');
+        cached = !cached;
+        entryId = cached ? result.entry_id : null;
+        status.textContent = cached ? '已确认并加入缓存' : '已撤销,后续提问将重新处理';
+      } catch (error) {
+        status.textContent = error.message || '缓存操作失败,请重试';
+      } finally {
+        label();
+        button.disabled = false;
+      }
+    });
+    bar.appendChild(button);
+  }
+  window.AnswerCacheUI = {attach};
+})();

+ 5 - 1
html/index.html

@@ -361,6 +361,8 @@
   <link rel="stylesheet" href="answer_markdown.css?v=20260904-qa">
   <link rel="stylesheet" href="answer_markdown.css?v=20260904-qa">
   <script src="vendor/marked.umd.js?v=17.0.5"></script>
   <script src="vendor/marked.umd.js?v=17.0.5"></script>
   <script src="answer_markdown.js?v=20260904-qa"></script>
   <script src="answer_markdown.js?v=20260904-qa"></script>
+  <link rel="stylesheet" href="answer_cache.css?v=20260907-cache">
+  <script src="answer_cache.js?v=20260907-cache"></script>
   <script>
   <script>
     const $ = (id) => document.getElementById(id);
     const $ = (id) => document.getElementById(id);
     const chatEl = $("chat");
     const chatEl = $("chat");
@@ -612,6 +614,7 @@
             const payload = JSON.parse(data);
             const payload = JSON.parse(data);
             if (typeof payload.answer === "string") answerText = payload.answer;
             if (typeof payload.answer === "string") answerText = payload.answer;
             renderAnswer(answerEl, answerText);
             renderAnswer(answerEl, answerText);
+            AnswerCacheUI.attach(assistantBubble, payload, threadId, apiBase());
             finishProgressFlow(progressEl, "完成");
             finishProgressFlow(progressEl, "完成");
             setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
             setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
           } catch (_) {}
           } catch (_) {}
@@ -676,7 +679,8 @@
       } else if (payload.status === "ok") {
       } else if (payload.status === "ok") {
         hideConfirm();
         hideConfirm();
         if (activeProgressEl) finishProgressFlow(activeProgressEl, "完成");
         if (activeProgressEl) finishProgressFlow(activeProgressEl, "完成");
-        appendMessage("assistant", payload.answer || "");
+        const answerBubble = appendMessage("assistant", payload.answer || "");
+        AnswerCacheUI.attach(answerBubble, payload, threadId, apiBase());
         setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
         setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
       } else {
       } else {
         hideConfirm();
         hideConfirm();

+ 1 - 1
html/knowledge_graph_3d.html

@@ -301,7 +301,7 @@
         <div class="spacer"></div>
         <div class="spacer"></div>
         <button id="chatToggle" class="chat-toggle">收起</button>
         <button id="chatToggle" class="chat-toggle">收起</button>
       </div>
       </div>
-      <iframe id="chatFrame" src="index.html?v=20260907-multiselect" title="申勤物业知识助手"></iframe>
+      <iframe id="chatFrame" src="index.html?v=20260907-cache" title="申勤物业知识助手"></iframe>
     </div>
     </div>
   </div>
   </div>
 
 

+ 92 - 0
scripts/benchmark_qa_planning.py

@@ -0,0 +1,92 @@
+"""Compare planning with both optimizations off/on; real LLM calls require authorization.
+
+--execute additionally runs schema-validated read-only Neo4j queries. No raw query
+results are saved. Planning times exclude confirmation, query execution and answer.
+"""
+from __future__ import annotations
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+from time import perf_counter
+from unittest.mock import patch
+from step3_qa_agent.agent import nodes, production, llm
+from step3_qa_agent.retrieval.production_query import compile_query
+from step2_graph_building.runtime import release_snapshot
+
+def query_fingerprint(plan, context):
+    if release_snapshot()['data_version'] != context['build_id']:
+        raise RuntimeError('数据版本变化,取消对比')
+    driver = production.get_driver()
+    results = []
+    for step in plan['steps']:
+        spec = step['params']
+        compiled = compile_query(spec, context['schema'], context['build_id'])
+        records = driver.execute_query(compiled.query, **compiled.params).records
+        aliases = {n['alias']: n['type'] for n in spec['nodes']}
+        columns = [(str((aliases[c['alias']], c.get('op', 'field'), c.get('field'))), c['as'])
+                   for c in spec.get('select', []) + spec.get('aggregates', [])]
+        rows = [sorted((identity, row.get(label)) for identity, label in columns) for row in records]
+        encoded = sorted(json.dumps(row, ensure_ascii=False, sort_keys=True, default=str) for row in rows)
+        results.append({'rows': len(records), 'truncated': len(records) > compiled.limit,
+                        'sha256': hashlib.sha256(json.dumps(encoded, ensure_ascii=False).encode()).hexdigest()})
+    if release_snapshot()['data_version'] != context['build_id']:
+        raise RuntimeError('查询期间数据版本变化,取消对比')
+    return results
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--question', action='append')
+    parser.add_argument('--repeat', type=int, default=1)
+    parser.add_argument('--execute', action='store_true', help='实际执行受控只读查询,仅保存结果摘要')
+    parser.add_argument('--output', default='.runtime/qa/planning-benchmark.json')
+    args = parser.parse_args()
+    if args.repeat < 1:
+        parser.error('--repeat 必须大于0')
+    questions = args.question or ['共有多少名员工?', '岗位名称包含保安的员工有多少名?',
+                                   '张三的岗位名称是什么?', '按岗位名称分别统计员工人数']
+    output = Path(args.output)
+    output.parent.mkdir(parents=True, exist_ok=True)
+    report = {'scope': 'understand + production_plan only; both switches off vs on',
+              'executed_read_only_queries': args.execute, 'runs': []}
+    original_chat = llm.get_chat
+    def bounded_chat(*a, **kw):
+        model = original_chat(*a, **kw)
+        model.request_timeout = 90
+        model.max_retries = 0
+        return model
+    for repeat in range(args.repeat):
+        for index, question in enumerate(questions):
+            for enabled in ([False, True] if (repeat + index) % 2 == 0 else [True, False]):
+                row = {'question': question, 'repeat': repeat, 'optimizations_enabled': enabled}
+                started = perf_counter()
+                try:
+                    with patch.dict(os.environ, {
+                            'QA_FAST_PLAN_ENABLED': str(enabled).lower(),
+                            'QA_COMPACT_INTERMEDIATE_ENABLED': str(enabled).lower()}), \
+                         patch.object(llm, 'get_chat', side_effect=bounded_chat):
+                        state = {'question': question}
+                        state.update(nodes.understand(state))
+                        state.update(production.plan(state))
+                    row.update(total_ms=round((perf_counter() - started) * 1000, 2),
+                               build_id=state['qa_context']['build_id'], trace=state['trace'],
+                               plan=state['plan'], candidate=state.get('simple_query'))
+                    if args.execute:
+                        row['query_results'] = query_fingerprint(state['plan'], state['qa_context'])
+                    row['status'] = 'ok'
+                except Exception as exc:
+                    row.update(status='error', error_type=type(exc).__name__,
+                               total_ms=round((perf_counter() - started) * 1000, 2))
+                report['runs'].append(row)
+                output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
+                trace = row.get('trace', [])
+                print(json.dumps({'question': question, 'optimized': enabled, 'status': row['status'],
+                                  'ms': row['total_ms'], 'mode': trace[-1].get('mode') if trace else None,
+                                  'reason': trace[-1].get('reason') if trace else None,
+                                  'llm_streams': sum(len(t.get('llm_calls', [])) for t in trace)}, ensure_ascii=False), flush=True)
+    print('Saved: ' + str(output), flush=True)
+    return 1 if any(r['status'] != 'ok' for r in report['runs']) else 0
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 299 - 0
scripts/test_answer_cache.py

@@ -0,0 +1,299 @@
+"""Approved cache: no external Redis, LLM or Neo4j connections."""
+from copy import deepcopy
+import json
+from types import SimpleNamespace
+import unittest
+from unittest.mock import patch
+from fastapi import HTTPException
+from langgraph.checkpoint.memory import InMemorySaver
+from step3_qa_agent.agent import nodes, production
+from step3_qa_agent.agent.graph import build_agent_graph
+from step4_web import api
+from step4_web.answer_cache import (ApprovedAnswerCache, prepare_request, cache_key,
+                                    eligibility, restored_state, review_info, ACTIVE_VERSION, APPROVE_CURRENT)
+from scripts.test_simple_plan import CONTEXT, state as simple_state
+from step3_qa_agent.agent.simple_plan import build_simple_plan
+
+RELEASE = {'data_version': 'v1', 'schema_version': 'schema1'}
+Q = '共有多少名员工?'
+
+class FakeRedis:
+    def __init__(self):
+        self.entries = {ACTIVE_VERSION: 'v1'}
+        self.ttls = {}
+        self.writes = 0
+    async def get(self, key):
+        return self.entries.get(key)
+    async def set(self, key, value, ex=None):
+        self.entries[key] = value
+        self.ttls[key] = ex
+        self.writes += 1
+    async def eval(self, script, count, key, *args):
+        if script == APPROVE_CURRENT:
+            fence, raw, version = args
+            if self.entries.get(fence) != version:
+                return 0
+            await self.set(key, raw)
+            return 1
+        raw, = args
+        if self.entries.get(key) == raw:
+            del self.entries[key]
+            return 1
+        return 0
+
+def completed():
+    s = simple_state()
+    s.update(answer='共有2名员工,按工号去重。', answer_id='answer1', fit=True,
+             cache_request=prepare_request('t1', Q, {}, RELEASE),
+             subgraph={'results': {'s1': {'rows': [{'记录数': 2}], 'truncated': False,
+                        'aggregate': True, 'data_version': 'v1'}}, 'suggestions': {'options': []}},
+             trace=[{'node': 'understand'}])
+    s['plan'] = build_simple_plan(s)[0]
+    return s
+
+class CacheTests(unittest.IsolatedAsyncioTestCase):
+    async def asyncSetUp(self):
+        self.redis = FakeRedis()
+        self.cache = ApprovedAnswerCache(self.redis)
+
+    async def test_lookup_never_populates_cache(self):
+        self.assertIsNone(await self.cache.lookup(completed()['cache_request']))
+        self.assertEqual(self.redis.writes, 0)
+
+    async def test_only_approved_answer_hits_permanently(self):
+        s = completed()
+        entry = await self.cache.approve(s)
+        loaded = await self.cache.lookup(s['cache_request'])
+        self.assertEqual(loaded['payload']['answer'], s['answer'])
+        self.assertTrue(loaded['approved'])
+        self.assertIsNone(self.redis.ttls[cache_key(s['cache_request'])])
+        self.assertNotIn('expires_at', entry)
+        self.assertTrue(cache_key(s['cache_request']).startswith('ka:qa:approved:v1:'))
+        self.assertEqual(entry['entry_id'], 'answer1')
+
+    async def test_old_inflight_approval_rejected_after_version_switch(self):
+        self.redis.entries[ACTIVE_VERSION] = 'v2'
+        with self.assertRaises(ValueError):
+            await self.cache.approve(completed())
+        self.assertEqual(self.redis.writes, 0)
+
+    async def test_scope_version_schema_context_and_question_are_separate(self):
+        s = completed()
+        await self.cache.approve(s)
+        for field in ('scope', 'data_version', 'schema_version', 'context', 'question'):
+            request = {**s['cache_request'], field: 'different'}
+            self.assertIsNone(await self.cache.lookup(request))
+
+    async def test_no_semantic_or_negation_matching(self):
+        s = completed()
+        await self.cache.approve(s)
+        for q in ('员工共有几人?', '没有证书的员工有多少?', '共有多少名员工!'):
+            self.assertIsNone(await self.cache.lookup(prepare_request('t1', q, {}, RELEASE)))
+
+    async def test_contextual_and_relative_questions_are_not_cacheable(self):
+        for question in ('他们有多少人', '这些员工的岗位', '今年入职人数'):
+            s = completed()
+            s['question'] = question
+            s['cache_request']['question'] = question
+            self.assertTrue(eligibility(s))
+            with self.assertRaises(ValueError):
+                await self.cache.approve(s)
+        self.assertEqual(self.redis.writes, 0)
+
+    async def test_failed_truncated_modified_and_partial_answers_rejected(self):
+        variations = []
+        s = completed(); s['fit'] = False; variations.append(s)
+        s = completed(); s['category'] = '闲聊'; variations.append(s)
+        s = completed(); s['plan_feedback'] = '换成项目'; variations.append(s)
+        s = completed(); s['subgraph']['results']['s1']['truncated'] = True; variations.append(s)
+        s = completed(); s['subgraph']['results']['s1']['data_version'] = 'old'; variations.append(s)
+        s = completed(); s['subgraph']['results'] = {}; variations.append(s)
+        s = completed(); s['subgraph']['error'] = 'failed'; variations.append(s)
+        s = completed(); s['subgraph']['suggestions']['options'] = ['candidate']; variations.append(s)
+        s = completed(); s['trace'].append({'node': 'value_choice'}); variations.append(s)
+        s = completed(); s['answer'] = ''; variations.append(s)
+        for s in variations:
+            self.assertTrue(eligibility(s))
+            with self.assertRaises(ValueError):
+                await self.cache.approve(s)
+        self.assertEqual(self.redis.writes, 0)
+
+    async def test_corrupt_unapproved_entries_miss(self):
+        s = completed()
+        entry = await self.cache.approve(s)
+        key = cache_key(s['cache_request'])
+        for value in ('broken', '[]', json.dumps({**entry, 'approved': False})):
+            self.redis.entries[key] = value
+            self.assertIsNone(await self.cache.lookup(s['cache_request']))
+
+    async def test_cache_read_timeout_falls_back(self):
+        import asyncio
+        async def slow(key):
+            await asyncio.sleep(1)
+        self.cache.timeout_seconds = .001
+        with patch.object(self.redis, 'get', side_effect=slow):
+            self.assertIsNone(await self.cache.lookup(completed()['cache_request']))
+
+    async def test_revoke_and_old_revoke_cannot_delete_new_approval(self):
+        s = completed()
+        await self.cache.approve(s)
+        s['answer_id'] = 'new-answer'
+        await self.cache.approve(s)
+        with self.assertRaises(ValueError):
+            await self.cache.revoke(s['cache_request'], 'answer1')
+        self.assertIsNotNone(await self.cache.lookup(s['cache_request']))
+        await self.cache.revoke(s['cache_request'], 'new-answer')
+        self.assertIsNone(await self.cache.lookup(s['cache_request']))
+
+    async def test_repeat_preserves_original_context_but_other_context_misses(self):
+        first = prepare_request('t1', Q, {}, RELEASE)
+        prev = {'rounds': [{'question': Q, 'answer': '2', 'schema_build_id': 'v1',
+                            'cache_context': first['context']}],
+                'messages': [{'role': 'user', 'content': Q}, {'role': 'assistant', 'content': '2'}]}
+        self.assertEqual(prepare_request('t1', Q, prev, RELEASE), first)
+        prev['rounds'].append({'question': '只看保安', 'answer': '1'})
+        self.assertNotEqual(prepare_request('t1', Q, prev, RELEASE), first)
+
+    async def test_oversize_not_eligible_and_never_written(self):
+        s = completed(); s['answer'] = 'x' * 300000
+        self.assertFalse(review_info(s, 'cp')['eligible'])
+        with self.assertRaises(ValueError):
+            await self.cache.approve(s)
+        self.assertEqual(self.redis.writes, 0)
+
+    async def test_neo4j_records_keep_named_columns(self):
+        from neo4j import Record
+        s = completed()
+        s['subgraph']['results']['s1']['rows'] = [Record({'记录数': 2})]
+        await self.cache.approve(s)
+        hit = await self.cache.lookup(s['cache_request'])
+        self.assertEqual(hit['payload']['subgraph']['results']['s1']['rows'], [{'记录数': 2}])
+
+class ApiCacheTests(unittest.IsolatedAsyncioTestCase):
+    async def asyncSetUp(self):
+        self.redis = FakeRedis()
+        self.cache = ApprovedAnswerCache(self.redis)
+        s = simple_state()
+        self.planner = patch.object(production, 'chat_json', return_value=build_simple_plan(s)[0])
+        self.patches = [
+            patch.object(api, '_graph', build_agent_graph(InMemorySaver())),
+            patch.object(api, '_answer_cache', self.cache),
+            patch.object(api, 'release_snapshot', return_value=RELEASE),
+            patch.object(api, '_write_api_log'),
+            patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)),
+            patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx),
+            patch.object(nodes, 'chat_json', return_value={'category': '图谱检索',
+                'slots': {}, 'data_needs': s['simple_query_needs']}),
+            self.planner,
+            patch.object(production, 'chat_text', return_value='共有2名员工,按工号去重。'),
+            patch.object(production, 'release_snapshot', return_value={'data_version': 'v1'}),
+            patch.object(production, 'get_driver', return_value=SimpleNamespace(
+                execute_query=lambda *a, **kw: SimpleNamespace(records=[{'记录数': 2}])))]
+        self.mocks = [p.start() for p in self.patches]
+        self.addCleanup(lambda: [p.stop() for p in reversed(self.patches)])
+
+    async def ask(self, question=Q, reuse=True, auto=True):
+        events = [e async for e in api._astream_run('t1', question, auto, reuse)]
+        return events[-1]
+
+    def body(self, state, **extra):
+        review = state['cache_review']
+        return api.AnswerReviewRequest(answer_id=review['answer_id'],
+                                       checkpoint_id=review['checkpoint_id'], **extra)
+
+    async def test_full_flow_requires_click_then_repeat_uses_no_models(self):
+        first = (await self.ask())['state']
+        self.assertTrue(first['cache_review']['eligible'])
+        self.assertEqual(self.redis.writes, 0)
+        result = await api.approve_answer('t1', self.body(first))
+        self.assertEqual(self.redis.writes, 1)
+        calls = [self.mocks[i].call_count for i in (6, 7, 8)]
+        hit = (await self.ask())['state']
+        self.assertTrue(hit['cache_review']['hit'])
+        self.assertEqual(hit['answer'], first['answer'])
+        self.assertEqual([self.mocks[i].call_count for i in (6, 7, 8)], calls)
+        self.assertEqual(len(hit['rounds']), 2)
+        self.assertNotEqual(hit['answer_id'], first['answer_id'])
+        await api.revoke_answer('t1', self.body(hit, entry_id=result['entry_id']))
+        fresh = (await self.ask())['state']
+        self.assertFalse(fresh['cache_review']['hit'])
+
+    async def test_reuse_switch_bypasses_cache(self):
+        first = (await self.ask())['state']
+        await api.approve_answer('t1', self.body(first))
+        next_answer = (await self.ask(reuse=False))['state']
+        self.assertFalse(next_answer['cache_review']['hit'])
+        self.assertEqual(self.mocks[6].call_count, 2)
+
+    async def test_approval_binds_immutable_old_checkpoint(self):
+        first = (await self.ask())['state']
+        await self.ask('人员有多少?')
+        await api.approve_answer('t1', self.body(first))
+        record = await self.cache.lookup(first['cache_request'])
+        self.assertEqual(record['payload']['answer_id'], first['answer_id'])
+
+    async def test_wrong_answer_or_thread_rejected(self):
+        first = (await self.ask())['state']
+        wrong = self.body(first).model_copy(update={'answer_id': 'forged'})
+        for thread, body in [('t1', wrong), ('another', self.body(first))]:
+            with self.assertRaises(HTTPException):
+                await api.approve_answer(thread, body)
+        self.assertEqual(self.redis.writes, 0)
+
+    async def test_stale_approval_rejected(self):
+        first = (await self.ask())['state']
+        with patch.object(api, 'release_snapshot', return_value={**RELEASE, 'data_version': 'v2'}):
+            with self.assertRaises(HTTPException) as exc:
+                await api.approve_answer('t1', self.body(first))
+        self.assertEqual(exc.exception.status_code, 409)
+        self.assertEqual(self.redis.writes, 0)
+
+    async def test_manual_plan_confirmation_is_not_answer_approval(self):
+        event = await self.ask(auto=False)
+        self.assertEqual(event['type'], 'confirm')
+        self.assertEqual(self.redis.writes, 0)
+        done = [e async for e in api._astream_resume('t1', '确认')][-1]['state']
+        self.assertTrue(done['cache_review']['eligible'])
+        self.assertEqual(self.redis.writes, 0)
+        await api.approve_answer('t1', self.body(done))
+        self.assertEqual(self.redis.writes, 1)
+
+    async def test_client_cannot_supply_answer_content(self):
+        from pydantic import ValidationError
+        with self.assertRaises(ValidationError):
+            api.AnswerReviewRequest(answer_id='a', checkpoint_id='b', answer='forged')
+
+    async def test_write_failure_does_not_report_success(self):
+        first = (await self.ask())['state']
+        with patch.object(self.redis, 'set', side_effect=TimeoutError):
+            with self.assertRaises(HTTPException) as exc:
+                await api.approve_answer('t1', self.body(first))
+        self.assertEqual(exc.exception.status_code, 503)
+
+
+    async def test_modified_plan_stays_ineligible_after_confirmation(self):
+        await self.ask(auto=False)
+        edits = [e async for e in api._astream_resume('t1', '请修改查询条件')]
+        self.assertEqual(edits[-1]['type'], 'confirm')
+        final = [e async for e in api._astream_resume('t1', '确认')][-1]['state']
+        self.assertTrue(final['cache_modified'])
+        self.assertFalse(final['cache_review']['eligible'])
+        with self.assertRaises(HTTPException):
+            await api.approve_answer('t1', self.body(final))
+
+    async def test_policy_change_invalidates_old_approval(self):
+        first = (await self.ask())['state']
+        with patch.dict('os.environ', {'QA_ANSWER_CACHE_POLICY_VERSION': 'changed'}):
+            with self.assertRaises(HTTPException):
+                await api.approve_answer('t1', self.body(first))
+
+    async def test_revoke_race_not_reported_as_success(self):
+        first = (await self.ask())['state']
+        result = await api.approve_answer('t1', self.body(first))
+        with patch.object(self.redis, 'eval', return_value=0):
+            with self.assertRaises(HTTPException) as exc:
+                await api.revoke_answer('t1', self.body(first, entry_id=result['entry_id']))
+        self.assertEqual(exc.exception.status_code, 409)
+
+if __name__ == '__main__':
+    unittest.main(verbosity=2)

+ 82 - 0
scripts/test_answer_cache_retention.py

@@ -0,0 +1,82 @@
+"""Offline cache lifecycle contracts; never connects to business services."""
+import json
+import unittest
+from unittest.mock import patch
+from step4_web import answer_cache_retention as retention
+from step4_web.answer_cache import PREFIX, ACTIVE_VERSION, DELETE_IF_SAME
+
+class Redis:
+    def __init__(self):
+        self.entries = {}
+        self.ttls = {}
+    def set(self, key, raw):
+        self.entries[key] = raw
+        self.ttls.pop(key, None)
+    def get(self, key):
+        return self.entries.get(key)
+    def scan_iter(self, match, count):
+        return (k for k in list(self.entries) if k.startswith(PREFIX))
+    def eval(self, script, count, key, raw, *args):
+        if self.entries.get(key) != raw:
+            return 0
+        if script == DELETE_IF_SAME:
+            del self.entries[key]
+            self.ttls.pop(key, None)
+        else:
+            self.set(key, args[0])
+        return 1
+
+class RetentionTests(unittest.TestCase):
+    def setUp(self):
+        self.redis = Redis()
+        for name, version in [('old1', 'v0'), ('old2', 'v-1'), ('current', 'v1')]:
+            self.redis.set(PREFIX + name, json.dumps({'request': {'data_version': version},
+                                                    'approved': True, 'expires_at': 100}))
+            self.redis.ttls[PREFIX + name] = 60
+        self.redis.set('checkpoint:thread1', 'keep')
+        self.redis.set('other:cache', 'keep')
+    def test_all_old_versions_removed_current_migrated_context_untouched(self):
+        report = retention.clean_entries(self.redis, 'v1')
+        self.assertEqual(report, {'removed': 2, 'preserved': 1})
+        self.assertNotIn(PREFIX + 'old1', self.redis.entries)
+        self.assertNotIn(PREFIX + 'old2', self.redis.entries)
+        self.assertNotIn(PREFIX + 'current', self.redis.ttls)
+        self.assertNotIn('expires_at', json.loads(self.redis.get(PREFIX + 'current')))
+        self.assertEqual(self.redis.get('checkpoint:thread1'), 'keep')
+        self.assertEqual(self.redis.get('other:cache'), 'keep')
+        self.assertEqual(self.redis.get(ACTIVE_VERSION), 'v1')
+    def test_completed_version_skips_scan_but_new_version_cleans(self):
+        retention.clean_entries(self.redis, 'v1')
+        with patch.object(self.redis, 'scan_iter', side_effect=AssertionError):
+            self.assertTrue(retention.clean_entries(self.redis, 'v1')['already_clean'])
+        self.assertEqual(retention.clean_entries(self.redis, 'v2')['removed'], 1)
+    def test_failure_leaves_retry_pending_and_fences_old_writes(self):
+        with patch.object(self.redis, 'eval', side_effect=TimeoutError):
+            with self.assertRaises(TimeoutError):
+                retention.clean_entries(self.redis, 'v1')
+        self.assertIsNone(self.redis.get(retention.CLEANED_VERSION))
+        self.assertEqual(self.redis.get(ACTIVE_VERSION), 'v1')
+        self.assertEqual(retention.clean_entries(self.redis, 'v1')['removed'], 2)
+    def test_changed_entry_not_deleted_by_stale_scan(self):
+        original = self.redis.eval
+        def race(script, count, key, raw, *args):
+            if key == PREFIX + 'old1':
+                self.redis.set(key, json.dumps({'request': {'data_version': 'v1'}}))
+            return original(script, count, key, raw, *args)
+        with patch.object(self.redis, 'eval', side_effect=race):
+            retention.clean_entries(self.redis, 'v1')
+        self.assertIn(PREFIX + 'old1', self.redis.entries)
+    def test_unreadable_own_entry_removed(self):
+        self.redis.set(PREFIX + 'broken', 'broken')
+        self.assertEqual(retention.clean_entries(self.redis, 'v1')['removed'], 3)
+    def test_outage_reports_warning(self):
+        with patch.object(retention, 'get_settings', side_effect=TimeoutError):
+            self.assertIn('warning', retention.cleanup_answer_cache_for_release('v1'))
+    def test_busy_publication_defers_cleanup(self):
+        with patch('step4_web.data_update.update_lock', side_effect=RuntimeError), patch.object(
+                retention, 'cleanup_answer_cache_for_release') as cleanup:
+            self.assertIn('warning', retention.retry_cleanup())
+            cleanup.assert_not_called()
+
+if __name__ == '__main__':
+    unittest.main()

+ 83 - 0
scripts/test_answer_cache_ui.cjs

@@ -0,0 +1,83 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const {chromium} = require('playwright');
+const root = path.resolve(__dirname, '../html');
+(async () => {
+  const browser = await chromium.launch({headless:true,channel:'msedge'});
+  try {
+    const page = await browser.newPage({viewport:{width:1280,height:900}});
+    const requests = [];
+    let failNext = false;
+    await page.route('**/*', async route => {
+      const url = new URL(route.request().url());
+      if (url.pathname.endsWith('/answer-cache')) {
+        requests.push({method:route.request().method(), path:url.pathname, body:route.request().postDataJSON()});
+        if (failNext) { failNext=false; return route.fulfill({status:409,json:{detail:'数据已更新,请重新提问后确认'}}); }
+        return route.fulfill({json:{status:'ok',entry_id:'entry1'}});
+      }
+      if (url.pathname === '/api/data-quality') return route.fulfill({json:{data_version:'v',total:0,datasets:{}}});
+      if (url.pathname === '/api/runtime') return route.fulfill({json:{data_version:'v',schema_version:'s'}});
+      if (url.pathname.startsWith('/output/')) return route.fulfill({json:{nodes:[],links:[],relations:[],meta:{}}});
+      const file = path.join(root, url.pathname === '/' ? 'knowledge_graph_3d.html' : url.pathname);
+      if (!file.startsWith(root) || !fs.existsSync(file)) return route.fulfill({status:404,body:''});
+      return route.fulfill({body:fs.readFileSync(file),contentType:file.endsWith('.js')?'application/javascript':file.endsWith('.css')?'text/css':'text/html'});
+    });
+    await page.goto('http://qa.test/');
+    assert.ok((await page.locator('#chatFrame').getAttribute('src')).includes('20260907-cache'));
+    const frame = await (await page.locator('#chatFrame').elementHandle()).contentFrame();
+    await frame.waitForFunction(() => typeof askStream === 'function' && Boolean(window.AnswerCacheUI));
+    await frame.evaluate(async () => {
+      const original=window.fetch;
+      const payload={status:'ok',answer:'## 员工人数\n\n共有2名员工。',elapsed_sec:.01,
+        cache_review:{eligible:true,answer_id:'answer1',checkpoint_id:'checkpoint1',cached:false,hit:false}};
+      const sse='event: answer_chunk\ndata: '+JSON.stringify({text:'处理中'})+'\n\n' +
+                'event: done\ndata: '+JSON.stringify(payload)+'\n\n';
+      window.fetch=async () => new Response(sse);
+      try {await askStream('员工有多少人',true,true);} finally {window.fetch=original;}
+    });
+    assert.equal(requests.length,0,'rendering an answer must not cache it');
+    const button=frame.locator('.answer-cache-button').last();
+    await button.click();
+    await frame.waitForFunction(() => document.querySelector('.answer-cache-button')?.textContent==='撤销缓存');
+    assert.equal(requests.length,1);
+    assert.equal(requests[0].method,'POST');
+    assert.deepEqual(requests[0].body,{answer_id:'answer1',checkpoint_id:'checkpoint1',entry_id:null});
+    const originalThreadPath=requests[0].path;
+    await frame.evaluate(() => { sessionStorage.setItem('kg_thread_id','new-thread'); });
+    await button.click();
+    await frame.waitForFunction(() => document.querySelector('.answer-cache-button')?.textContent==='答案正确,加入缓存');
+    assert.equal(requests[1].method,'DELETE');
+    assert.equal(requests[1].path,originalThreadPath,'old answer keeps its own thread scope');
+    assert.equal(requests[1].body.entry_id,'entry1');
+    failNext=true;
+    await button.click();
+    await frame.waitForFunction(() => document.querySelector('.answer-cache-status').textContent.includes('数据已更新'));
+    assert.equal(await button.innerText(),'答案正确,加入缓存');
+    assert.equal(await button.isEnabled(),true);
+    await button.click();
+    await frame.waitForFunction(() => document.querySelector('.answer-cache-button').textContent==='撤销缓存');
+    // Resume rendering gets the same explicit approval control.
+    await frame.evaluate(async () => {
+      const original=window.fetch;
+      window.fetch=async () => new Response(JSON.stringify({status:'ok',answer:'确认后完成',
+        cache_review:{eligible:true,answer_id:'answer2',checkpoint_id:'checkpoint2',cached:false}}));
+      try {await resumeThread('确认');} finally {window.fetch=original;}
+    });
+    assert.equal(await frame.locator('.answer-cache-button').count(),2);
+    await frame.evaluate(() => {
+      const bubble=appendMessage('assistant','历史确认答案');
+      AnswerCacheUI.attach(bubble,{cache_review:{eligible:true,answer_id:'hit',checkpoint_id:'cphit',
+        cached:true,hit:true,entry_id:'entry1'}},'t1',apiBase());
+      const incomplete=appendMessage('assistant','不完整结果');
+      AnswerCacheUI.attach(incomplete,{cache_review:{eligible:false,reason:'结果截断'}},'t1',apiBase());
+    });
+    assert.equal(await frame.getByText('已复用你确认的历史答案',{exact:true}).count(),1);
+    assert.equal(await frame.getByText('暂不缓存:结果截断',{exact:true}).count(),1);
+    assert.equal(await frame.locator('.answer-cache-button').count(),3);
+    await page.setViewportSize({width:390,height:844});
+    await frame.locator('.answer-cache-button').last().scrollIntoViewIfNeeded();
+    assert.ok(await frame.locator('.answer-cache-button').last().isVisible());
+    console.log('PASS root iframe: explicit approval only, bound answer/checkpoint/thread, revoke, stale-error retry, resume, hit badge, excluded answer, mobile');
+  } finally {await browser.close();}
+})().catch(e=>{console.error(e);process.exitCode=1;});

+ 18 - 1
scripts/test_runtime_workflow.py

@@ -57,7 +57,8 @@ class UpdateTests(unittest.TestCase):
         self.write_template('人员信息',['工号','姓名'])
         self.write_template('人员信息',['工号','姓名'])
         self.write_template('项目信息',['项目编号','负责人编号'])
         self.write_template('项目信息',['项目编号','负责人编号'])
         self.write_relation()
         self.write_relation()
-        self.patches=[patch.object(update,'ROOT',self.root),
+        self.patches=[patch.object(update,'cleanup_answer_cache_for_release',return_value={'removed': 0}),
+                      patch.object(update,'ROOT',self.root),
                       patch.object(update,'MANIFEST',self.root/'output/current_release.json'),
                       patch.object(update,'MANIFEST',self.root/'output/current_release.json'),
                       patch.object(runtime,'ROOT',self.root),
                       patch.object(runtime,'ROOT',self.root),
                       patch.object(runtime,'MANIFEST',self.root/'output/current_release.json'),
                       patch.object(runtime,'MANIFEST',self.root/'output/current_release.json'),
@@ -109,6 +110,22 @@ class UpdateTests(unittest.TestCase):
         return update.update_data(mode,fetcher=self.fetch,assessor=kwargs.get('assessor',self.assess),
         return update.update_data(mode,fetcher=self.fetch,assessor=kwargs.get('assessor',self.assess),
                                   analyzer=self.analyze,driver=self.driver)
                                   analyzer=self.analyze,driver=self.driver)
 
 
+    def test_cache_cleanup_only_after_successful_publication(self):
+        def cleaned(version):
+            manifest = json.loads(update.MANIFEST.read_text(encoding='utf-8'))
+            self.assertEqual(manifest['data_version'], version)
+            return {'warning': 'Redis unavailable; retry pending'}
+        with patch.object(update, 'cleanup_answer_cache_for_release', side_effect=cleaned) as cleanup:
+            report = self.run_update()
+            self.assertTrue(report['ok'])
+            self.assertIn('warning', report['answer_cache_cleanup'])
+            cleanup.assert_called_once_with(report['data_version'])
+        with patch.object(update, 'cleanup_answer_cache_for_release') as cleanup:
+            with patch.object(self, 'fetch', side_effect=ValueError('fetch failed')):
+                with self.assertRaises(ValueError):
+                    self.run_update()
+            cleanup.assert_not_called()
+
     def test_dms_updates_assessment_but_preserves_schema_bytes(self):
     def test_dms_updates_assessment_but_preserves_schema_bytes(self):
         first=self.run_update()
         first=self.run_update()
         before=runtime.release_snapshot()
         before=runtime.release_snapshot()

+ 307 - 0
scripts/test_simple_plan.py

@@ -0,0 +1,307 @@
+"""Offline correctness and model-call regression for the simple planning path."""
+from copy import deepcopy
+from types import SimpleNamespace
+import os
+import unittest
+from unittest.mock import patch
+from langgraph.checkpoint.memory import InMemorySaver
+from langgraph.types import Command
+from step3_qa_agent.agent import nodes, production, llm
+from step3_qa_agent.agent.graph import build_agent_graph
+from step3_qa_agent.agent.simple_plan import build_simple_plan
+from step3_qa_agent.agent.timing import measure_llm_calls, llm_calls
+from step3_qa_agent.retrieval.production_query import compile_query
+
+SCHEMA = {'meta': {'schema_version': 2}, 'nodes': [
+    {'id': '人员信息', 'active': True, 'attributes': ['工号', '姓名', '岗位名称', '部门'],
+     'identity_fields': ['工号'], 'multivalue_fields': {'岗位名称': 1}},
+    {'id': '项目信息', 'attributes': ['项目编号', '项目名称']}],
+    'relations': [{'id': 'works', 'source': '人员信息', 'target': '项目信息', 'type': '服务于'}]}
+CONTEXT = {'schema': SCHEMA, 'build_id': 'v1', 'catalog': [], 'assessment_text': '',
+           'selected_datasets': ['人员信息'], 'loaded_datasets': [], 'selection_source': 'understanding',
+           'issues': []}
+
+def state(question='共有多少名员工?'):
+    return {'question': question, 'category': '图谱检索', 'qa_context': deepcopy(CONTEXT),
+            'slots': {'anchors': [], 'filters': {}, 'aggregation': '汇总'},
+            'simple_query_needs': {'datasets': ['人员信息'], 'relations': []},
+            'simple_query': {'kind': 'count', 'dataset': '人员信息', 'filters': [],
+                             'fields': [], 'limit': 50, 'complete': True}}
+
+class SimplePlanTests(unittest.TestCase):
+    def setUp(self):
+        self.env = patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': 'true'})
+        self.env.start()
+        self.addCleanup(self.env.stop)
+
+    def test_count_is_distinct_node_query_not_schema_count(self):
+        s = state()
+        plan, _ = build_simple_plan(s)
+        spec = plan['steps'][0]['params']
+        compiled = compile_query(spec, SCHEMA, 'v1')
+        self.assertIn('count(DISTINCT n._kg_id)', compiled.query)
+        self.assertEqual(compiled.params['build'], 'v1')
+        with patch.object(production, 'chat_json') as model:
+            result = production.plan(s)
+        model.assert_not_called()
+        self.assertEqual(result['trace'][-1]['mode'], 'simple')
+        self.assertEqual(result['trace'][-1]['llm_calls'], [])
+
+    def test_property_query_keeps_anchor_and_provenance(self):
+        s = state('张三的岗位名称是什么?')
+        s['slots'] = {'anchors': [{'raw': '张三'}]}
+        s['simple_query'].update(kind='list', fields=['姓名', '岗位名称'],
+             filters=[{'field': '姓名', 'op': 'eq', 'value': '张三'}])
+        plan, _ = build_simple_plan(s)
+        compiled = compile_query(plan['steps'][0]['params'], SCHEMA, 'v1')
+        self.assertEqual(compiled.params['v0'], '张三')
+        self.assertIn('_sources', compiled.query)
+        self.assertIn('any(x IN CASE', compiled.query)
+
+    def test_list_preserves_all_and_filters_and_limit(self):
+        s = state('列出运营部岗位名称包含保安的员工姓名')
+        s['slots'] = {'filters': {'部门': '运营部', '岗位': '保安'}}
+        s['simple_query'].update(kind='list', fields=['工号', '姓名'], limit=20,
+            filters=[{'field': '部门', 'op': 'eq', 'value': '运营部'},
+                     {'field': '岗位名称', 'op': 'contains', 'value': '保安'}])
+        plan, _ = build_simple_plan(s)
+        compiled = compile_query(plan['steps'][0]['params'], SCHEMA, 'v1')
+        self.assertEqual((compiled.params['v0'], compiled.params['v1']), ('运营部', '保安'))
+        self.assertEqual(compiled.params['result_limit'], 21)
+        self.assertIn(' AND ', compiled.query)
+
+    def test_complex_questions_reject_even_when_model_claims_complete(self):
+        for question in ['他们有多少人', '按部门统计人数', '每个项目多少人', '保安或电工有多少',
+                         '没有证书的员工', '去年入职人数', '工资大于5000人数',
+                         '前10名员工', '删除所有员工', '比较项目人数']:
+            with self.subTest(question=question):
+                self.assertIsNone(build_simple_plan(state(question))[0])
+
+    def test_relation_and_invalid_selection_never_disappear(self):
+        for needs in [{'datasets': ['人员信息'], 'relations': ['unknown']},
+                      {'datasets': ['人员信息', '项目信息'], 'relations': ['works']},
+                      {'datasets': ['人员信息', 'unknown'], 'relations': []}]:
+            s = state()
+            s['simple_query_needs'] = needs
+            self.assertIsNone(build_simple_plan(s)[0])
+
+    def test_dropped_anchor_or_filter_is_rejected(self):
+        for slots in [{'anchors': [{'raw': '张三'}]}, {'filters': {'岗位': '保安'}},
+                      {'time_range': {'start': '2026-01'}}, {'filters': {'岗位': ['保安', '电工']}}]:
+            s = state('张三是保安吗')
+            s['slots'] = slots
+            self.assertIsNone(build_simple_plan(s)[0])
+
+    def test_unknown_field_and_nonliteral_value_fall_back(self):
+        for condition in [{'field': '不存在', 'op': 'eq', 'value': '员工'},
+                          {'field': '岗位名称', 'op': 'eq', 'value': '保安'},
+                          {'field': '岗位名称', 'op': 'ne', 'value': '员工'}]:
+            s = state()
+            s['simple_query']['filters'] = [condition]
+            self.assertIsNone(build_simple_plan(s)[0])
+
+    def test_malformed_candidates_fail_closed(self):
+        for candidate in [None, [], {}, {'complete': True},
+                          {**state()['simple_query'], 'fields': None},
+                          {**state()['simple_query'], 'fields': [['姓名']]},
+                          {**state()['simple_query'], 'limit': True},
+                          {**state()['simple_query'], 'limit': 201},
+                          {**state()['simple_query'], 'filters': [None]},
+                          {**state()['simple_query'], 'extra': 'ignored?'},
+                          {**state()['simple_query'], 'kind': 'sum'},
+                          {**state()['simple_query'], 'complete': 'true'}]:
+            s = state()
+            s['simple_query'] = candidate
+            self.assertIsNone(build_simple_plan(s)[0])
+
+    def test_feedback_always_uses_full_planner(self):
+        original, _ = build_simple_plan(state())
+        for key in ['plan_feedback', 'run_feedback', 'slots_feedback', 'feedback']:
+            s = state()
+            s[key] = '改成只查保安'
+            with patch.object(production, 'chat_json', return_value=original) as model:
+                result = production.plan(s)
+            model.assert_called_once()
+            self.assertEqual(result['trace'][-1]['mode'], 'llm')
+            self.assertEqual(result['trace'][-1]['reason'], 'feedback_requires_replanning')
+
+    def test_disabled_and_legacy_fall_back(self):
+        original, _ = build_simple_plan(state())
+        with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': 'false'}):
+            with patch.object(production, 'chat_json', return_value=original) as model:
+                self.assertEqual(production.plan(state())['trace'][-1]['reason'], 'disabled')
+                model.assert_called_once()
+        s = state()
+        s['qa_context']['schema']['meta']['schema_version'] = 1
+        self.assertIsNone(build_simple_plan(s)[0])
+
+    def model_output(self):
+        s = state()
+        return {'category': '图谱检索', 'slots': s['slots'], 'data_needs': s['simple_query_needs'],
+                'simple_query': s['simple_query']}
+
+    def test_real_graph_skips_second_model_and_still_confirms(self):
+        with patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
+             patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
+             patch.object(nodes, 'chat_json', return_value=self.model_output()) as understanding, \
+             patch.object(production, 'chat_json') as planner:
+            graph = build_agent_graph(InMemorySaver())
+            result = graph.invoke({'question': '共有多少名员工?'},
+                                  {'configurable': {'thread_id': 'simple'}})
+        understanding.assert_called_once()
+        planner.assert_not_called()
+        self.assertEqual(result['__interrupt__'][0].value['type'], 'confirm_plan')
+        self.assertEqual(result['trace'][-1]['mode'], 'simple')
+
+    def test_confirm_edit_replans_and_then_executes(self):
+        original, _ = build_simple_plan(state())
+        driver = SimpleNamespace(execute_query=lambda *a, **kw: SimpleNamespace(records=[{'记录数': 2}]))
+        with patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
+             patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
+             patch.object(nodes, 'chat_json', return_value=self.model_output()), \
+             patch.object(production, 'chat_json', return_value=original) as planner, \
+             patch.object(production, 'get_driver', return_value=driver), \
+             patch.object(production, 'release_snapshot', return_value={'data_version': 'v1'}), \
+             patch.object(production, 'chat_text', return_value='共2条记录'):
+            graph = build_agent_graph(InMemorySaver())
+            config = {'configurable': {'thread_id': 'edit'}}
+            graph.invoke({'question': '共有多少名员工?'}, config)
+            result = graph.invoke(Command(resume='请重新核对计数口径'), config)
+            self.assertIn('__interrupt__', result)
+            planner.assert_called_once()
+            result = graph.invoke(Command(resume='确认'), config)
+            self.assertEqual(result['answer'], '共2条记录')
+            self.assertEqual(result['subgraph']['results']['s1']['rows'], [{'记录数': 2}])
+
+    def test_understanding_resets_old_candidate_and_switch_restores_prompt(self):
+        for enabled in ['true', 'false']:
+            s = state()
+            with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': enabled}), \
+                 patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
+                 patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
+                 patch.object(nodes, 'chat_json', return_value={'category': '闲聊'}) as model:
+                result = nodes.understand(s)
+            self.assertIsNone(result['simple_query'])
+            self.assertIsNone(result['simple_query_needs'])
+            self.assertEqual('simple_query' in model.call_args.args[0], enabled == 'true')
+
+    def test_full_planner_still_retries_invalid_plan(self):
+        s = state()
+        s['simple_query'] = None
+        original, _ = build_simple_plan(state())
+        with patch.object(production, 'chat_json', side_effect=[{'steps': []}, original]) as model:
+            result = production.plan(s)
+        self.assertEqual(model.call_count, 2)
+        self.assertEqual(result['trace'][-1]['validation_attempts'], 2)
+
+    def test_minimal_candidate_matches_explicit_defaults(self):
+        full = state()
+        minimal = state()
+        minimal['simple_query'] = {'kind': 'count', 'dataset': '人员信息', 'complete': True}
+        self.assertEqual(build_simple_plan(full), build_simple_plan(minimal))
+        plan, _ = build_simple_plan(minimal)
+        step = plan['steps'][0]
+        self.assertNotIn('fields', step)
+        self.assertNotIn('depends', step)
+        self.assertNotIn('select', step['params'])
+        self.assertNotIn('filters', step['params'])
+        self.assertNotIn('limit', step['params'])
+
+    def test_compact_full_plan_preserves_filters_and_does_not_add_empty_fields(self):
+        s = state('按部门分别统计运营部员工')
+        s['simple_query'] = None
+        plan = {'steps': [{'step_id': 's1', 'tool': '图谱查询', 'params': {
+            'nodes': [{'alias': 'n', 'type': '人员信息'}],
+            'filters': [{'alias': 'n', 'field': '部门', 'op': 'eq', 'value': '运营部'}],
+            'select': [{'alias': 'n', 'field': '部门', 'as': '部门'}],
+            'aggregates': [{'alias': 'n', 'op': 'count', 'as': '人数'}]}}]}
+        with patch.dict(os.environ, {'QA_COMPACT_INTERMEDIATE_ENABLED': 'true'}), \
+             patch.object(production, 'chat_json', return_value=plan) as model:
+            result = production.plan(s)
+        self.assertEqual(result['plan'], plan)
+        self.assertIn('省略所有空数组', model.call_args.args[0])
+        self.assertNotIn('"history"', model.call_args.args[1])
+        self.assertNotIn('"feedback"', model.call_args.args[1])
+        compiled = compile_query(plan['steps'][0]['params'], SCHEMA, 'v1')
+        self.assertEqual(compiled.params['v0'], '运营部')
+        self.assertIn('n.`部门` AS `部门`', compiled.query)
+
+    def test_both_switches_off_restore_original_prompt(self):
+        s = state()
+        original, _ = build_simple_plan(s)
+        with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': 'false',
+                                     'QA_COMPACT_INTERMEDIATE_ENABLED': 'false'}), \
+             patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
+             patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
+             patch.object(nodes, 'chat_json', return_value=self.model_output()) as understand_model, \
+             patch.object(production, 'chat_json', return_value=original) as plan_model:
+            s.update(nodes.understand(s))
+            production.plan(s)
+        self.assertNotIn('simple_query', understand_model.call_args.args[0])
+        self.assertNotIn('仅输出紧凑JSON', understand_model.call_args.args[0])
+        self.assertIn('不需要的数组填[]', plan_model.call_args.args[0])
+
+    def test_invalid_switch_is_not_silently_accepted(self):
+        from step2_graph_building.config import get_qa_fast_plan_enabled
+        with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': 'maybe'}):
+            with self.assertRaises(ValueError):
+                get_qa_fast_plan_enabled()
+
+    def test_graph_records_one_vs_two_real_wrapper_streams(self):
+        import json
+        original, _ = build_simple_plan(state())
+        output = self.model_output()
+        class Model:
+            def stream(self, messages):
+                body = output if '问题理解器' in messages[0]['content'] else original
+                yield SimpleNamespace(content=json.dumps(body, ensure_ascii=False), usage_metadata=None)
+        for enabled, expected in [('true', 1), ('false', 2)]:
+            with patch.dict(os.environ, {'QA_FAST_PLAN_ENABLED': enabled,
+                                         'QA_COMPACT_INTERMEDIATE_ENABLED': enabled}), \
+                 patch.object(nodes, 'load_question_context', return_value=deepcopy(CONTEXT)), \
+                 patch.object(nodes, 'select_assessments', side_effect=lambda ctx, *a, **kw: ctx), \
+                 patch.object(llm, 'get_chat', return_value=Model()) as client:
+                result = build_agent_graph(InMemorySaver()).invoke({'question': '共有多少名员工?'},
+                    {'configurable': {'thread_id': enabled}})
+            self.assertEqual(client.call_count, expected)
+            self.assertEqual(sum(len(t['llm_calls']) for t in result['trace']), expected)
+            self.assertEqual(result['__interrupt__'][0].value['type'], 'confirm_plan')
+
+class TimingTests(unittest.TestCase):
+    def test_json_retries_and_usage_are_measured(self):
+        class Model:
+            def __init__(self):
+                self.count = 0
+            def stream(self, messages):
+                self.count += 1
+                yield SimpleNamespace(content='', usage_metadata=None)
+                yield SimpleNamespace(content='invalid' if self.count == 1 else '{"ok":true}',
+                                      usage_metadata={'input_tokens': 4, 'output_tokens': 2})
+        with measure_llm_calls() as calls, patch.object(llm, 'get_chat', return_value=Model()):
+            self.assertEqual(llm.chat_json('system', 'user'), {'ok': True})
+        self.assertEqual(len(calls), 2)
+        self.assertEqual(calls[-1]['usage']['input_tokens'], 4)
+        self.assertGreaterEqual(calls[-1]['duration_ms'], calls[-1]['first_text_ms'])
+        self.assertNotIn('system', str(calls))
+        self.assertIsNone(llm_calls.get())
+
+    def test_nested_collectors_do_not_leak(self):
+        with measure_llm_calls() as outer:
+            with measure_llm_calls() as inner:
+                self.assertIs(llm_calls.get(), inner)
+            self.assertIs(llm_calls.get(), outer)
+        self.assertIsNone(llm_calls.get())
+
+    def test_stream_failure_is_measured_and_propagated(self):
+        def fail(messages):
+            raise RuntimeError('transport failed')
+            yield
+        with measure_llm_calls() as calls, patch.object(llm, 'get_chat',
+                return_value=SimpleNamespace(stream=fail)):
+            with self.assertRaises(RuntimeError):
+                llm.chat_json('system', 'user')
+        self.assertEqual(calls[0]['status'], 'error')
+        self.assertIsNone(calls[0]['first_text_ms'])
+
+if __name__ == '__main__':
+    unittest.main(verbosity=2)

+ 28 - 0
src/step2_graph_building/config.py

@@ -106,6 +106,34 @@ def get_relation_path(dotenv_path: str | Path | None = None) -> Path:
     return path
     return path
 
 
 
 
+def _optional_bool(name: str, default: str = 'true') -> bool:
+    _load()
+    value = os.getenv(name, default).strip().lower()
+    if value not in {'true', 'false', '1', '0'}:
+        raise ValueError(f'{name} 必须为 true/false 或 1/0')
+    return value in {'true', '1'}
+
+
+def get_qa_answer_cache_policy() -> tuple[str, str]:
+    _load()
+    return os.getenv('QA_ANSWER_CACHE_POLICY_VERSION', '1'), os.getenv('DEEPSEEK_MODEL', '')
+
+
+def get_qa_answer_cache_config() -> bool:
+    # Approved answers live until a data-version change or explicit revocation.
+    return _optional_bool('QA_ANSWER_CACHE_ENABLED')
+
+
+def get_qa_fast_plan_enabled() -> bool:
+    """Use validated single-dataset candidates before calling the full planner."""
+    return _optional_bool('QA_FAST_PLAN_ENABLED')
+
+
+def get_qa_compact_intermediate_enabled() -> bool:
+    """Generate only required JSON fields in production intermediate responses."""
+    return _optional_bool('QA_COMPACT_INTERMEDIATE_ENABLED')
+
+
 def get_embedding_model_dir() -> Path:
 def get_embedding_model_dir() -> Path:
     _load()
     _load()
     raw = os.getenv("EMBEDDING_MODEL_DIR", "models/Qwen3-Embedding-0.6B").strip()
     raw = os.getenv("EMBEDDING_MODEL_DIR", "models/Qwen3-Embedding-0.6B").strip()

+ 33 - 2
src/step3_qa_agent/agent/llm.py

@@ -4,10 +4,12 @@ from __future__ import annotations
 
 
 import json
 import json
 import re
 import re
+from time import perf_counter
 
 
 from langchain_openai import ChatOpenAI
 from langchain_openai import ChatOpenAI
 
 
 from step2_graph_building.config import get_settings
 from step2_graph_building.config import get_settings
+from .timing import llm_calls
 
 
 
 
 SYSTEM_SAFETY = (
 SYSTEM_SAFETY = (
@@ -54,6 +56,35 @@ def _chunk_text(chunk) -> str:
     return str(c or "")
     return str(c or "")
 
 
 
 
+def _measured_stream(llm, messages):
+    """Record one SDK stream; internal transport retries are included in duration."""
+    started = perf_counter()
+    record = {'first_chunk_ms': None, 'first_text_ms': None, 'output_chars': 0,
+              'input_chars': sum(len(m['content']) for m in messages), 'usage': None}
+    calls = llm_calls.get()
+    if calls is not None:
+        calls.append(record)
+    try:
+        for chunk in llm.stream(messages):
+            elapsed = round((perf_counter() - started) * 1000, 2)
+            if record['first_chunk_ms'] is None:
+                record['first_chunk_ms'] = elapsed
+            piece = _chunk_text(chunk)
+            if piece and record['first_text_ms'] is None:
+                record['first_text_ms'] = elapsed
+            record['output_chars'] += len(piece)
+            usage = getattr(chunk, 'usage_metadata', None)
+            if isinstance(usage, dict) and usage:
+                record['usage'] = usage
+            yield chunk
+        record['status'] = 'ok'
+    except Exception:
+        record['status'] = 'error'
+        raise
+    finally:
+        record['duration_ms'] = round((perf_counter() - started) * 1000, 2)
+
+
 def chat_json(system: str, user: str, model: str | None = None,
 def chat_json(system: str, user: str, model: str | None = None,
               on_chunk=None) -> dict:
               on_chunk=None) -> dict:
     """JSON 输出:底层用流式收集(结果等价,方便后续透传进度)。"""
     """JSON 输出:底层用流式收集(结果等价,方便后续透传进度)。"""
@@ -61,7 +92,7 @@ def chat_json(system: str, user: str, model: str | None = None,
     last_err = None
     last_err = None
     for _ in range(3):
     for _ in range(3):
         text = ""
         text = ""
-        for chunk in llm.stream([
+        for chunk in _measured_stream(llm, [
             {"role": "system", "content": system + "\n" + SYSTEM_SAFETY},
             {"role": "system", "content": system + "\n" + SYSTEM_SAFETY},
             {"role": "user", "content": user},
             {"role": "user", "content": user},
         ]):
         ]):
@@ -82,7 +113,7 @@ def chat_text(system: str, user: str, model: str | None = None,
     """文本输出:底层用流式收集;on_chunk 可回调每个增量(API 透传进度用)。"""
     """文本输出:底层用流式收集;on_chunk 可回调每个增量(API 透传进度用)。"""
     llm = get_chat(model=model, temperature=0.3)
     llm = get_chat(model=model, temperature=0.3)
     text = ""
     text = ""
-    for chunk in llm.stream([
+    for chunk in _measured_stream(llm, [
         {"role": "system", "content": system + "\n" + SYSTEM_SAFETY},
         {"role": "system", "content": system + "\n" + SYSTEM_SAFETY},
         {"role": "user", "content": user},
         {"role": "user", "content": user},
     ]):
     ]):

+ 19 - 1
src/step3_qa_agent/agent/nodes.py

@@ -7,6 +7,7 @@ import json
 import inspect
 import inspect
 import re
 import re
 from datetime import date
 from datetime import date
+from time import perf_counter
 
 
 from langgraph.types import interrupt
 from langgraph.types import interrupt
 from langgraph.config import get_stream_writer
 from langgraph.config import get_stream_writer
@@ -20,6 +21,9 @@ from .derived import RULES, apply_derived_expr, extract_condition
 from .llm import chat_json, chat_text
 from .llm import chat_json, chat_text
 from .schema_context import build_schema_text
 from .schema_context import build_schema_text
 from .question_context import load_question_context, question_schema_text, select_assessments
 from .question_context import load_question_context, question_schema_text, select_assessments
+from .simple_plan import SIMPLE_QUERY_GUIDE
+from .timing import measure_llm_calls
+from step2_graph_building.config import get_qa_fast_plan_enabled, get_qa_compact_intermediate_enabled
 
 
 
 
 INTENT_EDGES: dict[str, list[str]] = {
 INTENT_EDGES: dict[str, list[str]] = {
@@ -70,6 +74,7 @@ def _norm_month(v: str) -> str:
 
 
 def understand(state: dict) -> dict:
 def understand(state: dict) -> dict:
     """问题理解:一次 LLM 调用完成 闲聊/图谱分类 + 图谱检索槽位抽取(替代 classify+fill_slots)。"""
     """问题理解:一次 LLM 调用完成 闲聊/图谱分类 + 图谱检索槽位抽取(替代 classify+fill_slots)。"""
+    started = perf_counter()
     ctx = _history_context(state)
     ctx = _history_context(state)
     context = load_question_context()
     context = load_question_context()
     sys_p = f"""你是申勤物业问题理解器。先判断用户问题是闲聊还是需要基于业务知识图谱检索回答;
     sys_p = f"""你是申勤物业问题理解器。先判断用户问题是闲聊还是需要基于业务知识图谱检索回答;
@@ -96,19 +101,32 @@ data_needs 规则:
 {question_schema_text(context)}
 {question_schema_text(context)}
 【可用数据评估目录(本阶段不注入评估正文)】
 【可用数据评估目录(本阶段不注入评估正文)】
 {json.dumps(context["catalog"], ensure_ascii=False)}"""
 {json.dumps(context["catalog"], ensure_ascii=False)}"""
+    fast_enabled = (context['schema'].get('meta', {}).get('schema_version') == 2
+                    and get_qa_fast_plan_enabled())
+    if fast_enabled:
+        sys_p += '\n' + SIMPLE_QUERY_GUIDE
+    if context['schema'].get('meta', {}).get('schema_version') == 2 and get_qa_compact_intermediate_enabled():
+        sys_p += ('\n输出要求:仅输出紧凑JSON,不输出分析、解释或Markdown。'
+                  'slots仅保留有值的键,空anchors/filters、null时间、空筛选值无需输出。'
+                  'data_needs保留datasets和relations;不得为了缩短输出遗漏条件、实体或返回要求。')
     user_p = f"用户问题:{state['question']}"
     user_p = f"用户问题:{state['question']}"
     if state.get("slots_feedback"):
     if state.get("slots_feedback"):
         user_p += f"\n用户对上次抽取结果的修改意见:{state['slots_feedback']}"
         user_p += f"\n用户对上次抽取结果的修改意见:{state['slots_feedback']}"
-    out = chat_json(sys_p, user_p)
+    with measure_llm_calls() as calls:
+        out = chat_json(sys_p, user_p)
     cat = "图谱检索" if str(out.get("category", "")).strip() in ("图谱检索", "知识图谱", "检索") else "闲聊"
     cat = "图谱检索" if str(out.get("category", "")).strip() in ("图谱检索", "知识图谱", "检索") else "闲聊"
     slots = out.get("slots") if isinstance(out.get("slots"), dict) else {}
     slots = out.get("slots") if isinstance(out.get("slots"), dict) else {}
     needs = out.get("data_needs") if cat == "图谱检索" else {"datasets": [], "relations": []}
     needs = out.get("data_needs") if cat == "图谱检索" else {"datasets": [], "relations": []}
     context = select_assessments(context, needs, question=state["question"], slots=slots)
     context = select_assessments(context, needs, question=state["question"], slots=slots)
     return {"category": cat, "slots": slots, "qa_context": context,
     return {"category": cat, "slots": slots, "qa_context": context,
+            "simple_query": out.get('simple_query') if fast_enabled and cat == '图谱检索' else None,
+            "simple_query_needs": needs if fast_enabled and cat == '图谱检索' else None,
             "plan_feedback": "", "run_feedback": "", "feedback": "", "iterations": 0,
             "plan_feedback": "", "run_feedback": "", "feedback": "", "iterations": 0,
             "answer": "", "chat_answer": "", "subgraph": {}, "plan": {},
             "answer": "", "chat_answer": "", "subgraph": {}, "plan": {},
             "messages": state.get("messages", []) + [{"role": "user", "content": state["question"]}],
             "messages": state.get("messages", []) + [{"role": "user", "content": state["question"]}],
             "trace": state.get("trace", []) + [{"node": "understand", "category": cat,
             "trace": state.get("trace", []) + [{"node": "understand", "category": cat,
+                                                "duration_ms": round((perf_counter() - started) * 1000, 2),
+                                                "llm_calls": calls,
                                                 "slots": slots,
                                                 "slots": slots,
                                                 "schema_build_id": context["build_id"],
                                                 "schema_build_id": context["build_id"],
                                                 "selected_datasets": context["selected_datasets"],
                                                 "selected_datasets": context["selected_datasets"],

+ 51 - 6
src/step3_qa_agent/agent/production.py

@@ -2,6 +2,7 @@
 from __future__ import annotations
 from __future__ import annotations
 import json
 import json
 from copy import deepcopy
 from copy import deepcopy
+from time import perf_counter
 from .value_clarification import find_candidates, selected_options
 from .value_clarification import find_candidates, selected_options
 from langgraph.types import interrupt
 from langgraph.types import interrupt
 from langgraph.config import get_stream_writer
 from langgraph.config import get_stream_writer
@@ -11,6 +12,9 @@ from ..retrieval.production_query import compile_query
 from .llm import chat_json, chat_text
 from .llm import chat_json, chat_text
 from .plan_presentation import describe_plan
 from .plan_presentation import describe_plan
 from .question_context import question_schema_text, assessment_context_text
 from .question_context import question_schema_text, assessment_context_text
+from .simple_plan import build_simple_plan
+from .timing import measure_llm_calls
+from step2_graph_building.config import get_qa_fast_plan_enabled, get_qa_compact_intermediate_enabled
 
 
 QUERY_GUIDE = """
 QUERY_GUIDE = """
 只输出JSON {"steps":[{"step_id":"s1","tool":"图谱查询","params":{
 只输出JSON {"steps":[{"step_id":"s1","tool":"图谱查询","params":{
@@ -32,21 +36,57 @@ eq/in/contains筛选同时支持标量与多值字段,按任一元素命中;
 """
 """
 
 
 
 
+COMPACT_QUERY_GUIDE = """
+仅输出紧凑JSON,不写分析/解释/Markdown:
+{"steps":[{"step_id":"s1","tool":"图谱查询","params":{"nodes":[{"alias":"n","type":"节点id"}],"select":[{"alias":"n","field":"真实字段","as":"列名"}]}}]}。
+params可选项:
+relations:[{source:"别名",target:"别名",id:"关系id"}];
+filters:[{alias:"别名",field:"真实字段",op:"eq|ne|contains|in|gt|gte|lt|lte|is_null|not_null",value:条件值}];
+aggregates:[{alias:"别名",op:"count|count_distinct|sum|avg|min|max",field:"字段",as:"列名"}];
+order_by:[{field:"返回列名",direction:"asc|desc"}];limit:1至200整数,默认50。
+省略所有空数组、默认limit、重复fields和depends;不得省略实际条件、关系、返回字段及统计口径。
+最多5步,每步独立执行,无步骤引用;每步1至4节点、最多3关系,所有节点连通。
+select与aggregates合用表示分组;只计数时省略select。count按节点id去重且省略field,
+人数只能计人员节点,不能计证书节点;count_distinct统计字段不同值。
+sum/avg/min/max只允许单节点的单值数值字段,禁止跨关系累加。
+eq/in/contains按多值字段任一元素命中;contains为文本包含,不能代表否定。
+数字比较用数字值;is_null/not_null省略value。明细上限不限制聚合前计数。
+复合概念拆为真实属性(如中级电工:级别=中级 AND 证书名称=电工),不猜同义值、不遗漏条件。
+字段仅用元图谱真实字段,不输出Cypher/SQL/脚本;评估报告不能充当事实答案。
+不支持则输出{"steps":[],"unsupported_reason":"简短说明"}。
+"""
+
+
 def context_text(state):
 def context_text(state):
     ctx=state['qa_context']
     ctx=state['qa_context']
     return question_schema_text(ctx)+'\n'+assessment_context_text(ctx)
     return question_schema_text(ctx)+'\n'+assessment_context_text(ctx)
 
 
 
 
 def plan(state):
 def plan(state):
-    prompt='你是生产知识图谱查询规划器。根据本轮问题、元图谱和评估制定最少的只读查询。\n'+QUERY_GUIDE
+    started = perf_counter()
+    proposed, reason = build_simple_plan(state) if get_qa_fast_plan_enabled() else (None, 'disabled')
+    if proposed is not None:
+        validate_plan(proposed, state['qa_context'])
+        return {'plan': proposed, 'run_feedback': '',
+                'trace': state.get('trace', []) + [{'node': 'production_plan', 'plan': proposed,
+                    'mode': 'simple', 'reason': reason, 'llm_calls': [],
+                    'duration_ms': round((perf_counter() - started) * 1000, 2)}]}
+    compact = get_qa_compact_intermediate_enabled()
+    guide = COMPACT_QUERY_GUIDE if compact else QUERY_GUIDE
+    prompt='你是生产知识图谱查询规划器。根据本轮问题、元图谱和评估制定最少的只读查询。\n'+guide
     prompt+='\n'+context_text(state)
     prompt+='\n'+context_text(state)
     history=[{'question':r.get('question'), 'answer':str(r.get('answer',''))[:600]}
     history=[{'question':r.get('question'), 'answer':str(r.get('answer',''))[:600]}
              for r in (state.get('rounds') or [])[-3:]]
              for r in (state.get('rounds') or [])[-3:]]
     payload={'question':state['question'], 'slots':state.get('slots',{}),
     payload={'question':state['question'], 'slots':state.get('slots',{}),
              'history':history, 'feedback':state.get('plan_feedback') or state.get('run_feedback','')}
              'history':history, 'feedback':state.get('plan_feedback') or state.get('run_feedback','')}
+    if compact:
+        payload = {key: value for key, value in payload.items() if value not in ('', {}, [])}
     proposed={}
     proposed={}
+    calls = []
     for attempt in range(2):
     for attempt in range(2):
-        proposed=chat_json(prompt,json.dumps(payload,ensure_ascii=False))
+        with measure_llm_calls() as attempt_calls:
+            proposed=chat_json(prompt,json.dumps(payload,ensure_ascii=False))
+        calls.extend(attempt_calls)
         try:
         try:
             validate_plan(proposed,state['qa_context'])
             validate_plan(proposed,state['qa_context'])
             break
             break
@@ -55,7 +95,9 @@ def plan(state):
                 proposed={'steps':[], 'unsupported_reason':'查询计划未通过结构校验,需重新明确问题。'}
                 proposed={'steps':[], 'unsupported_reason':'查询计划未通过结构校验,需重新明确问题。'}
             else:
             else:
                 prompt+='\n上次计划错误:'+str(exc)
                 prompt+='\n上次计划错误:'+str(exc)
-    return {'plan':proposed,'run_feedback':'','trace':state.get('trace',[])+[{'node':'production_plan','plan':proposed}]}
+    return {'plan':proposed,'run_feedback':'','trace':state.get('trace',[])+[{'node':'production_plan','plan':proposed,
+        'mode': 'llm', 'reason': reason, 'llm_calls': calls, 'validation_attempts': attempt + 1,
+        'duration_ms': round((perf_counter() - started) * 1000, 2)}]}
 
 
 
 
 def validate_plan(proposed,context):
 def validate_plan(proposed,context):
@@ -80,7 +122,8 @@ def confirm(state):
     message=describe_plan(state['plan'], state['qa_context']['schema'])
     message=describe_plan(state['plan'], state['qa_context']['schema'])
     reply=interrupt({'type':'confirm_plan','message':message})
     reply=interrupt({'type':'confirm_plan','message':message})
     ok=str(reply).strip().lower() in {'确认','是','继续','执行','ok','yes','y'}
     ok=str(reply).strip().lower() in {'确认','是','继续','执行','ok','yes','y'}
-    return {'plan_confirm':ok, 'plan_feedback':'' if ok else str(reply)}
+    return {'plan_confirm':ok, 'plan_feedback':'' if ok else str(reply),
+            'cache_modified': state.get('cache_modified', False) or not ok}
 
 
 
 
 def execute(state):
 def execute(state):
@@ -141,7 +184,7 @@ def clarify(state):
                 condition.update(op='eq' if len(unique_values) == 1 else 'in',
                 condition.update(op='eq' if len(unique_values) == 1 else 'in',
                                  value=unique_values[0] if len(unique_values) == 1 else unique_values)
                                  value=unique_values[0] if len(unique_values) == 1 else unique_values)
             validate_plan(updated, state['qa_context'])
             validate_plan(updated, state['qa_context'])
-            return {'plan': updated, 'plan_confirm': True,
+            return {'plan': updated, 'plan_confirm': True, 'cache_modified': True,
                     'trace': state.get('trace', []) + [{'node': 'value_choice', 'choices': choices}]}
                     'trace': state.get('trace', []) + [{'node': 'value_choice', 'choices': choices}]}
         message = ('请勾选一个或多个候选,也可输入多个序号或完整值(用逗号分隔),'
         message = ('请勾选一个或多个候选,也可输入多个序号或完整值(用逗号分隔),'
                    '或回复“都不是”。“确认”不会自动选择候选。')
                    '或回复“都不是”。“确认”不会自动选择候选。')
@@ -181,7 +224,9 @@ def answer(state):
                        on_chunk=on_chunk)
                        on_chunk=on_chunk)
     rounds=list(state.get('rounds',[]))
     rounds=list(state.get('rounds',[]))
     rounds.append({'question':state['question'],'answer':text,
     rounds.append({'question':state['question'],'answer':text,
-                   'schema_build_id':state['qa_context']['build_id'],'anchors':[]})
+                   'schema_build_id':state['qa_context']['build_id'],'anchors':[],
+                   'cache_context': (state.get('cache_request') or {}).get('context'),
+                   'answer_id': state.get('answer_id')})
     return {'answer':text,'rounds':rounds,
     return {'answer':text,'rounds':rounds,
             'messages':state.get('messages',[])+[{'role':'assistant','content':text}]}
             'messages':state.get('messages',[])+[{'role':'assistant','content':text}]}
 
 

+ 109 - 0
src/step3_qa_agent/agent/simple_plan.py

@@ -0,0 +1,109 @@
+"""Conservative single-dataset plans from the current understanding result."""
+from __future__ import annotations
+import re
+from ..retrieval.production_query import compile_query
+
+SIMPLE_QUERY_GUIDE = """
+生产图谱还需输出 simple_query(复杂或不确定时为 null)。仅当本轮问题完整地对应
+一个数据集的计数、属性查询或明细列表,且所有条件都是明确文本等于/包含的 AND 组合时,输出:
+{"kind":"count|list","dataset":"节点id","filters":[{"field":"真实字段","op":"eq|contains","value":"用户原词"}],
+ "fields":["明细返回字段"],"limit":50,"complete":true}。
+count 按该数据集节点主键去重计数,不能把证书数当人数。
+空 filters、空 fields 和默认 limit=50 可省略。例如总数查询仅需
+{"kind":"count","dataset":"节点id","complete":true},完整性声明 complete 不可省略。
+list 给出真实返回字段(最多8个,列表带标识字段),limit 为1至200,默认50。
+只允许原问题明确出现的筛选值,不猜测同义词;不得漏掉任何条件来简化问题。
+data_needs 必须只选择这个数据集且 relations=[]。complete=true 仅表示所有条件和返回要求已完整表达。
+跨表/关系、时间条件、数值比较、否定/或条件、分组/排序/比较/计算、历史指代、
+需参考评估正文才能确定口径或字段的情况,均输出 null,交给后续规划器。
+不要输出分析说明,不生成完整 steps 或 Cypher。闲聊时 simple_query=null。
+"""
+
+# Full planning is required even if the model incorrectly proposes a shortcut.
+COMPLEX_QUESTION = re.compile(
+    r'他们|她们|它们|这些|那些|上述|上面|之前|刚才|该项目|该人员|继续|分别|每个|各个|'
+    r'按.+(?:统计|分组|汇总)|分组|排序|排名|前\s*\d+|最高|最低|最多|最少|'
+    r'平均|合计|总额|占比|比例|比较|对比|相比|大于|小于|超过|不足|至少|至多|'
+    r'不含|不等于|不是|没有|未|排除|除了|或者|或|以及|同时|'
+    r'今年|去年|本月|上月|本周|今天|昨天|最近|截至|期间|\d{4}[年/-]|'
+    r'修改|删除|新增|更新|写入|设置|忽略|指令|提示词|[<>]'
+)
+
+def build_simple_plan(state):
+    """Return (plan, reason); invalid/incomplete candidates always fall back."""
+    context = state.get('qa_context') or {}
+    schema = context.get('schema') or {}
+    if schema.get('meta', {}).get('schema_version') != 2:
+        return None, 'legacy_schema'
+    if any(state.get(k) for k in ('plan_feedback', 'run_feedback', 'slots_feedback', 'feedback')):
+        return None, 'feedback_requires_replanning'
+    candidate = state.get('simple_query')
+    if not isinstance(candidate, dict) or candidate.get('complete') is not True:
+        return None, 'no_complete_candidate'
+    if (set(candidate) - {'kind', 'dataset', 'filters', 'fields', 'limit', 'complete'}
+            or not {'kind', 'dataset', 'complete'} <= set(candidate)):
+        return None, 'invalid_candidate_shape'
+    candidate = {'filters': [], 'fields': [], 'limit': 50, **candidate}
+    question = state.get('question', '')
+    if not isinstance(question, str) or COMPLEX_QUESTION.search(question):
+        return None, 'complex_question'
+    slots = state.get('slots') or {}
+    if slots.get('time_range') or slots.get('aggregation') in ('对比', '排序'):
+        return None, 'complex_slots'
+    dataset = candidate['dataset']
+    if not isinstance(dataset, str) or context.get('selected_datasets') != [dataset]:
+        return None, 'dataset_selection_mismatch'
+    if context.get('selection_source') != 'understanding':
+        return None, 'missing_semantic_selection'
+    needs = state.get('simple_query_needs')
+    if not isinstance(needs, dict) or needs.get('datasets') != [dataset] or needs.get('relations') != []:
+        return None, 'relation_or_ambiguous_selection'
+    matches = [n for n in schema.get('nodes', []) if n.get('id') == dataset]
+    if len(matches) != 1 or matches[0].get('active') is False:
+        return None, 'unknown_or_inactive_dataset'
+    fields, filters = candidate['fields'], candidate['filters']
+    if not isinstance(fields, list) or not all(isinstance(f, str) for f in fields):
+        return None, 'invalid_fields'
+    if len(fields) != len(set(fields)) or len(fields) > 8:
+        return None, 'invalid_fields'
+    if type(candidate['limit']) is not int or not 1 <= candidate['limit'] <= 200:
+        return None, 'invalid_limit'
+    if candidate['kind'] not in ('count', 'list'):
+        return None, 'unsupported_kind'
+    if (candidate['kind'] == 'count' and fields) or (candidate['kind'] == 'list' and not fields):
+        return None, 'invalid_projection'
+    if not isinstance(filters, list) or len(filters) > 6:
+        return None, 'invalid_filters'
+    compiled_filters = []
+    for condition in filters:
+        if not isinstance(condition, dict) or set(condition) != {'field', 'op', 'value'}:
+            return None, 'invalid_filters'
+        value = condition['value']
+        if condition['op'] not in ('eq', 'contains') or not isinstance(value, str) or not value.strip():
+            return None, 'unsupported_filter'
+        if value not in question:
+            return None, 'filter_value_not_in_question'
+        compiled_filters.append({'alias': 'n', **condition})
+    # Cross-check legacy slots to catch dropped named entities and explicit filters.
+    values = [f['value'] for f in filters]
+    anchors = slots.get('anchors') or []
+    slot_filters = slots.get('filters') or {}
+    if not isinstance(anchors, list) or not isinstance(slot_filters, dict):
+        return None, 'invalid_slots'
+    required = [a.get('raw') for a in anchors if isinstance(a, dict)]
+    required.extend(v for v in slot_filters.values() if v not in ('', None))
+    if any(not isinstance(v, str) or v not in values for v in required):
+        return None, 'uncovered_slot'
+    spec = {'nodes': [{'alias': 'n', 'type': dataset}], 'relations': [],
+            'filters': compiled_filters,
+            'select': [{'alias': 'n', 'field': f, 'as': f} for f in fields],
+            'aggregates': [{'alias': 'n', 'op': 'count', 'as': '记录数'}] if candidate['kind'] == 'count' else [],
+            'order_by': [], 'limit': candidate['limit']}
+    try:
+        compile_query(spec, schema, context['build_id'])
+    except (ValueError, TypeError, KeyError):
+        return None, 'compiler_rejected'
+    spec = {key: value for key, value in spec.items() if value != []}
+    if spec['limit'] == 50:
+        del spec['limit']
+    return {'steps': [{'step_id': 's1', 'tool': '图谱查询', 'params': spec}]}, 'single_dataset_' + candidate['kind']

+ 6 - 0
src/step3_qa_agent/agent/state.py

@@ -7,6 +7,10 @@ from typing import Any, TypedDict
 
 
 class AgentState(TypedDict, total=False):
 class AgentState(TypedDict, total=False):
     question: str
     question: str
+    answer_id: str               # 当前答案身份,绑定人工缓存确认
+    cache_request: dict[str, Any]
+    cache_hit: dict[str, Any]
+    cache_modified: bool         # 本轮计划/候选是否经用户修改
     user_id: str                 # 提问人身份(API 时由调用方传入)
     user_id: str                 # 提问人身份(API 时由调用方传入)
     category: str                # 闲聊 / 图谱检索
     category: str                # 闲聊 / 图谱检索
     chat_answer: str
     chat_answer: str
@@ -17,6 +21,8 @@ class AgentState(TypedDict, total=False):
     enable_reuse: bool            # 是否允许多轮答案复用判断(默认 True)
     enable_reuse: bool            # 是否允许多轮答案复用判断(默认 True)
     qa_context: dict[str, Any]   # 本轮元图谱快照、语义选择的数据评估和可用性记录
     qa_context: dict[str, Any]   # 本轮元图谱快照、语义选择的数据评估和可用性记录
     slots: dict[str, Any]        # LLM 原始槽位
     slots: dict[str, Any]        # LLM 原始槽位
+    simple_query: dict[str, Any] | None  # 本轮单数据集查询候选;每问重置
+    simple_query_needs: dict[str, Any] | None  # 原始语义选择,拒绝未知/被过滤的关系
     slots_feedback: str          # 用户对槽位的修改意见(回到槽位填充)
     slots_feedback: str          # 用户对槽位的修改意见(回到槽位填充)
     confirm: bool                # 槽位是否已确认
     confirm: bool                # 槽位是否已确认
     grounded: dict[str, Any]     # 接地后槽位(锚点→主键、意图→边、时间)
     grounded: dict[str, Any]     # 接地后槽位(锚点→主键、意图→边、时间)

+ 14 - 0
src/step3_qa_agent/agent/timing.py

@@ -0,0 +1,14 @@
+"""Per-node stream measurements isolated across concurrent requests."""
+from contextlib import contextmanager
+from contextvars import ContextVar
+
+llm_calls = ContextVar('qa_llm_calls', default=None)
+
+@contextmanager
+def measure_llm_calls():
+    calls = []
+    token = llm_calls.set(calls)
+    try:
+        yield calls
+    finally:
+        llm_calls.reset(token)

+ 183 - 0
src/step4_web/answer_cache.py

@@ -0,0 +1,183 @@
+"""User-approved answer cache, separate from LangGraph checkpoints.
+
+Exact matching only. Conversation/data/schema/policy changes produce different
+keys. Raw question/answer content is never accepted from the approval request.
+"""
+from __future__ import annotations
+import asyncio
+from copy import deepcopy
+from collections.abc import Mapping
+from datetime import datetime, timezone
+import hashlib
+import json
+import re
+
+from redis.exceptions import RedisError
+from step2_graph_building.config import get_qa_answer_cache_policy
+from step3_qa_agent.agent.production import validate_plan
+
+PREFIX = 'ka:qa:approved:v1:'
+ACTIVE_VERSION = 'ka:qa:approved:active_data_version'
+APPROVE_CURRENT = "if redis.call('GET',KEYS[2]) ~= ARGV[2] then return 0 end redis.call('SET',KEYS[1],ARGV[1]); return 1"
+MAX_BYTES = 256_000
+CONTEXTUAL = re.compile(r'他们|她们|它们|这些|那些|上述|上面|之前|刚才|该项目|该人员|继续|上次|这个|那个|这批|那批')
+RELATIVE = re.compile(r'今天|昨天|明天|今年|去年|本月|上月|下月|本周|上周|最近|截至|现在几岁')
+DELETE_IF_SAME = "if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end"
+
+def digest(value):
+    return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str,
+                                     separators=(',', ':')).encode()).hexdigest()
+
+def prepare_request(thread_id, question, previous, release):
+    question = question.strip()  # Do not drop punctuation, negation, digits or entity whitespace.
+    rounds = previous.get('rounds') or []
+    history = [{'question': r.get('question'), 'answer': r.get('answer'),
+                'anchors': r.get('anchors'), 'version': r.get('schema_build_id')} for r in rounds[-3:]]
+    context = digest({'rounds': history, 'messages': (previous.get('messages') or [])[-6:]})
+    # Repeating the immediately preceding identical question retains its original
+    # context, so the answer we just confirmed is reusable without context drift.
+    if (rounds and str(rounds[-1].get('question', '')).strip() == question
+            and rounds[-1].get('schema_build_id') == release['data_version']
+            and rounds[-1].get('cache_context')):
+        context = rounds[-1]['cache_context']
+    policy, model = get_qa_answer_cache_policy()
+    return {'question': question, 'scope': digest(thread_id), 'context': context,
+            'data_version': release['data_version'], 'schema_version': release['schema_version'],
+            'policy': policy, 'model': model}
+
+def cache_key(request):
+    return PREFIX + digest(request)
+
+def eligibility(state):
+    request = state.get('cache_request') or {}
+    if not request or request.get('question') != str(state.get('question', '')).strip():
+        return '缺少本轮问题记录'
+    if CONTEXTUAL.search(request['question']) or RELATIVE.search(request['question']):
+        return '依赖上下文或相对时间的问题暂不缓存'
+    context = state.get('qa_context') or {}
+    if state.get('category') != '图谱检索' or context.get('schema', {}).get('meta', {}).get('schema_version') != 2:
+        return '仅缓存完成的生产图谱回答'
+    if context.get('build_id') != request['data_version']:
+        return '数据版本已变化,请重新提问'
+    if not state.get('answer') or not state.get('fit') or not state.get('answer_id'):
+        return '回答尚未完整完成'
+    if state.get('cache_modified') or any(state.get(k) for k in ('plan_feedback', 'run_feedback', 'slots_feedback')):
+        return '修改过查询条件的回答暂不缓存'
+    subgraph = state.get('subgraph') or {}
+    if subgraph.get('error') or subgraph.get('unsupported_reason') or subgraph.get('clarification_declined'):
+        return '错误或未确认查询结果不缓存'
+    trace = state.get('trace') or []
+    last_understand = max((i for i, item in enumerate(trace) if item.get('node') == 'understand'), default=0)
+    if any(item.get('node') == 'value_choice' for item in trace[last_understand:]):
+        return '替换过候选条件的回答暂不缓存'
+    suggestions = subgraph.get('suggestions') or {}
+    if suggestions.get('options') or suggestions.get('error') or suggestions.get('limited'):
+        return '仍有澄清或不完整候选,暂不缓存'
+    plan = state.get('plan') or {}
+    try:
+        validate_plan(plan, context)
+    except (ValueError, TypeError, KeyError):
+        return '查询计划无效'
+    steps = plan.get('steps') or []
+    results = subgraph.get('results') or {}
+    if not steps or {s['step_id'] for s in steps} != set(results):
+        return '查询结果不完整'
+    for result in results.values():
+        if (result.get('truncated') or result.get('error') or result.get('rows') is None
+                or result.get('data_version') != request['data_version']):
+            return '结果截断、失败或版本不一致'
+    return ''
+
+def payload_from_state(state):
+    # Keep source provenance and the exact approved plan. Exclude old messages and
+    # trace to bound entry size and prevent restoring another turn's context.
+    def plain(value):
+        if isinstance(value, Mapping):
+            return {key: plain(item) for key, item in value.items()}
+        if isinstance(value, (list, tuple)):
+            return [plain(item) for item in value]
+        return value
+    payload = {key: plain(state.get(key)) for key in
+               ('question', 'answer', 'category', 'slots', 'qa_context', 'plan', 'subgraph', 'fit', 'answer_id')}
+    for result in payload['subgraph']['results'].values():
+        result['rows'] = [dict(row) for row in result['rows']]
+    return payload
+
+def review_info(state, checkpoint_id, enabled=True):
+    reason = eligibility(state) if enabled else '问答缓存未启用'
+    if not reason and len(json.dumps(payload_from_state(state), ensure_ascii=False, default=str).encode()) > MAX_BYTES - 4096:
+        reason = '回答及来源较大,暂不缓存'
+    hit = state.get('cache_hit') or {}
+    return {'eligible': not reason, 'reason': reason, 'answer_id': state.get('answer_id'),
+            'checkpoint_id': checkpoint_id, 'cached': bool(hit), 'hit': bool(hit),
+            'entry_id': hit.get('entry_id'), 'approved_at': hit.get('approved_at')}
+
+class ApprovedAnswerCache:
+    def __init__(self, redis, timeout_seconds=0.25):
+        self.redis = redis
+        self.timeout_seconds = timeout_seconds
+
+    async def lookup(self, request):
+        if CONTEXTUAL.search(request['question']) or RELATIVE.search(request['question']):
+            return None
+        try:
+            raw = await asyncio.wait_for(self.redis.get(cache_key(request)), self.timeout_seconds)
+            if not raw or len(raw) > MAX_BYTES:
+                return None
+            entry = json.loads(raw)
+            if (not isinstance(entry, dict) or entry.get('approved') is not True
+                    or entry.get('request') != request
+                    or not isinstance(entry.get('payload'), dict)):
+                return None
+            state = {**entry['payload'], 'cache_request': request}
+            if eligibility(state):
+                return None
+            return entry
+        except (TimeoutError, RedisError, ValueError, TypeError, KeyError, AttributeError):
+            return None  # A cache outage must not prevent the normal QA flow.
+
+    async def approve(self, state):
+        reason = eligibility(state)
+        if reason:
+            raise ValueError(reason)
+        request = state['cache_request']
+        entry = {'approved': True, 'entry_id': state['answer_id'], 'request': request,
+                 'approved_at': datetime.now(timezone.utc).isoformat(),
+                 'payload': payload_from_state(state)}
+        raw = json.dumps(entry, ensure_ascii=False, default=str, separators=(',', ':'))
+        if len(raw.encode()) > MAX_BYTES:
+            raise ValueError('回答及来源较大,暂不缓存')
+        saved = await asyncio.wait_for(self.redis.eval(APPROVE_CURRENT, 2, cache_key(request),
+            ACTIVE_VERSION, raw, request['data_version']), self.timeout_seconds)
+        if not saved:
+            raise ValueError('缓存数据版本已变化或尚未就绪,请重新查询后重试')
+        return entry
+
+    async def revoke(self, request, entry_id):
+        key = cache_key(request)
+        raw = await asyncio.wait_for(self.redis.get(key), self.timeout_seconds)
+        if not raw:
+            return
+        entry = json.loads(raw)
+        if entry.get('entry_id') != entry_id:
+            raise ValueError('缓存已被另一条确认记录替换,请重新查询')
+        removed = await asyncio.wait_for(self.redis.eval(DELETE_IF_SAME, 1, key, raw), self.timeout_seconds)
+        if not removed:
+            raise ValueError('缓存已变化,请重新查询后操作')
+
+def restored_state(entry, previous, request, answer_id):
+    state = deepcopy(entry['payload'])
+    question, answer = request['question'], state['answer']
+    state.update(question=question, answer_id=answer_id, cache_request=request,
+                 cache_hit={'entry_id': entry['entry_id'], 'approved_at': entry['approved_at']},
+                 chat_answer='', simple_query=None, simple_query_needs=None, cache_modified=False,
+                 plan_feedback='', run_feedback='', slots_feedback='', feedback='',
+                 grounded={}, capability={}, reuse={}, iterations=0,
+                 rounds=list(previous.get('rounds') or []) + [
+                     {'question': question, 'answer': answer, 'schema_build_id': request['data_version'],
+                      'anchors': [], 'cache_context': request['context'], 'answer_id': answer_id}],
+                 messages=list(previous.get('messages') or []) + [
+                     {'role': 'user', 'content': question}, {'role': 'assistant', 'content': answer}],
+                 trace=list(previous.get('trace') or []) + [{'node': 'answer_cache', 'hit': True,
+                                                             'llm_calls': []}])
+    return state

+ 62 - 0
src/step4_web/answer_cache_retention.py

@@ -0,0 +1,62 @@
+"""Clean only approved answers, serialized with publication by update_lock."""
+from __future__ import annotations
+import asyncio
+import json
+from redis import Redis
+from step2_graph_building.config import get_settings
+from step2_graph_building.runtime import release_snapshot
+from .answer_cache import PREFIX, ACTIVE_VERSION, DELETE_IF_SAME
+
+CLEANED_VERSION = 'ka:qa:approved:cleaned_data_version:v2'
+PERSIST_IF_SAME = "if redis.call('GET',KEYS[1]) == ARGV[1] then redis.call('SET',KEYS[1],ARGV[2]); return 1 else return 0 end"
+
+def clean_entries(redis, version):
+    if not isinstance(version, str) or not version:
+        raise ValueError('缺少当前数据版本')
+    # Fence old in-flight approvals before deleting anything.
+    redis.set(ACTIVE_VERSION, version)
+    completed = redis.get(CLEANED_VERSION)
+    if completed in (version, version.encode()):
+        return {'removed': 0, 'preserved': 0, 'already_clean': True}
+    report = {'removed': 0, 'preserved': 0}
+    for key in redis.scan_iter(match=PREFIX + '*', count=200):
+        raw = redis.get(key)
+        if raw is None:
+            continue
+        try:
+            entry = json.loads(raw)
+            current = entry.get('request', {}).get('data_version') == version
+        except (ValueError, TypeError, AttributeError):
+            current = False  # Invalid entries in our dedicated index are unusable.
+        if not current:
+            report['removed'] += redis.eval(DELETE_IF_SAME, 1, key, raw)
+        else:
+            entry.pop('expires_at', None)
+            migrated = json.dumps(entry, ensure_ascii=False, default=str, separators=(',', ':'))
+            report['preserved'] += redis.eval(PERSIST_IF_SAME, 1, key, raw, migrated)
+    redis.set(CLEANED_VERSION, version)
+    return report
+
+def cleanup_answer_cache_for_release(version):
+    """Caller holds update_lock; a Redis outage must not undo publication."""
+    try:
+        with Redis.from_url(get_settings().redis_url, socket_timeout=2,
+                            socket_connect_timeout=2) as redis:
+            return clean_entries(redis, version)
+    except Exception as exc:
+        return {'warning': '旧问答缓存清理未完成,将自动重试:' + type(exc).__name__}
+
+def retry_cleanup():
+    from .data_update import update_lock
+    try:
+        with update_lock():
+            return cleanup_answer_cache_for_release(release_snapshot()['data_version'])
+    except (RuntimeError, OSError, ValueError, KeyError) as exc:
+        return {'warning': '问答缓存清理暂缓:' + type(exc).__name__}
+
+async def cleanup_loop():
+    while True:
+        report = await asyncio.to_thread(retry_cleanup)
+        if report.get('warning'):
+            print(report['warning'], flush=True)
+        await asyncio.sleep(60)

+ 113 - 9
src/step4_web/api.py

@@ -13,33 +13,37 @@
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
+import asyncio
 import json
 import json
 import re
 import re
 import time
 import time
 import traceback
 import traceback
-from contextlib import asynccontextmanager
+from contextlib import asynccontextmanager, suppress
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 from typing import AsyncIterator
 from typing import AsyncIterator
 from uuid import uuid4
 from uuid import uuid4
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
-from fastapi import FastAPI
+from fastapi import FastAPI, HTTPException
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import FileResponse, StreamingResponse
 from fastapi.responses import FileResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from step3_qa_agent.agent.redis_checkpointer import PlainRedisSaver
 from step3_qa_agent.agent.redis_checkpointer import PlainRedisSaver
 from langgraph.types import Command
 from langgraph.types import Command
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
 
 
 from step3_qa_agent.agent.graph import build_agent_graph
 from step3_qa_agent.agent.graph import build_agent_graph
-from step2_graph_building.config import get_service_address, get_settings
+from step2_graph_building.config import get_service_address, get_settings, get_qa_answer_cache_config
+from step2_graph_building.runtime import release_snapshot
+from step4_web.answer_cache import ApprovedAnswerCache, prepare_request, restored_state, review_info, digest
+from step4_web.answer_cache_retention import retry_cleanup, cleanup_loop
 from step4_web.data_quality import router as data_quality_router
 from step4_web.data_quality import router as data_quality_router
 
 
 
 
 @asynccontextmanager
 @asynccontextmanager
 async def lifespan(_: FastAPI):
 async def lifespan(_: FastAPI):
-    global _graph
+    global _graph, _answer_cache
 
 
     from step2_graph_building.runtime import release_snapshot
     from step2_graph_building.runtime import release_snapshot
     from step2_graph_building.db import get_driver
     from step2_graph_building.db import get_driver
@@ -54,10 +58,22 @@ async def lifespan(_: FastAPI):
         settings.redis_url, ttl_seconds=settings.redis_context_ttl_seconds
         settings.redis_url, ttl_seconds=settings.redis_context_ttl_seconds
     ) as checkpointer:
     ) as checkpointer:
         _graph = build_agent_graph(checkpointer=checkpointer)
         _graph = build_agent_graph(checkpointer=checkpointer)
+        cache_enabled = get_qa_answer_cache_config()
+        _answer_cache = ApprovedAnswerCache(checkpointer.redis) if cache_enabled else None
+        cleanup_report = await asyncio.to_thread(retry_cleanup)
+        if cleanup_report.get('warning'):
+            print(cleanup_report['warning'], flush=True)
+        cleanup_task = asyncio.create_task(cleanup_loop())
         print("Redis 异步 checkpointer 已就绪", flush=True)
         print("Redis 异步 checkpointer 已就绪", flush=True)
 
 
-        yield
+        try:
+            yield
+        finally:
+            cleanup_task.cancel()
+            with suppress(asyncio.CancelledError):
+                await cleanup_task
     _graph = None
     _graph = None
+    _answer_cache = None
 
 
 
 
 app = FastAPI(title="申勤物业知识助手", version="0.1.0", lifespan=lifespan)
 app = FastAPI(title="申勤物业知识助手", version="0.1.0", lifespan=lifespan)
@@ -71,6 +87,7 @@ app.add_middleware(
 
 
 # 全局共享图:状态按 thread_id 隔离,由 PlainRedisSaver 持久化到原生 Redis
 # 全局共享图:状态按 thread_id 隔离,由 PlainRedisSaver 持久化到原生 Redis
 _graph = None
 _graph = None
+_answer_cache = None
 API_LOG_DIR = Path(__file__).resolve().parents[2] / "api_log"
 API_LOG_DIR = Path(__file__).resolve().parents[2] / "api_log"
 HTML_DIR = Path(__file__).resolve().parents[2] / "html"
 HTML_DIR = Path(__file__).resolve().parents[2] / "html"
 OUTPUT_DIR = Path(__file__).resolve().parents[2] / "output"
 OUTPUT_DIR = Path(__file__).resolve().parents[2] / "output"
@@ -101,6 +118,7 @@ async def health():
 
 
 # 节点 → 中文处理阶段说明(SSE progress 事件携带,前端可直接展示)
 # 节点 → 中文处理阶段说明(SSE progress 事件携带,前端可直接展示)
 NODE_LABELS: dict[str, str] = {
 NODE_LABELS: dict[str, str] = {
+    "answer_cache": "正在查询已确认的历史答案",
     "production_plan": "正在根据元图谱和数据评估规划查询",
     "production_plan": "正在根据元图谱和数据评估规划查询",
     "production_confirm": "等待确认查询计划",
     "production_confirm": "等待确认查询计划",
     "production_run": "正在查询最新业务图谱",
     "production_run": "正在查询最新业务图谱",
@@ -199,6 +217,7 @@ def _answer_payload(thread_id: str, st: dict, elapsed: float) -> dict:
         "subgraph": st.get("subgraph", {}),
         "subgraph": st.get("subgraph", {}),
         "rounds": st.get("rounds", []),
         "rounds": st.get("rounds", []),
         "elapsed_sec": round(elapsed, 2),
         "elapsed_sec": round(elapsed, 2),
+        "cache_review": st.get("cache_review"),
     }
     }
 
 
 
 
@@ -215,7 +234,33 @@ async def _astream_run(thread_id: str, query: str,
     """
     """
     t0 = time.monotonic()
     t0 = time.monotonic()
     config = _config(thread_id)
     config = _config(thread_id)
-    inp: object = {"question": query, "user_id": "api", "enable_reuse": enable_reuse}
+    graph = _get_graph()
+    inp: object = {"question": query, "user_id": "api", "enable_reuse": enable_reuse,
+                   "answer_id": uuid4().hex, "cache_request": {}, "cache_hit": {}, "cache_modified": False}
+    if _answer_cache is not None:
+        previous = await graph.aget_state(config)
+        try:
+            release = release_snapshot()
+            request = prepare_request(thread_id, query, previous.values or {}, release)
+            inp['cache_request'] = request
+            # A paused workflow must be resumed normally; never overwrite its checkpoint.
+            if enable_reuse and not previous.next:
+                yield {"type": "node", "node": "answer_cache", "payload": {}}
+                entry = await _answer_cache.lookup(request)
+                current = release_snapshot()
+                if (entry and current['data_version'] == request['data_version']
+                        and current['schema_version'] == request['schema_version']):
+                    state = restored_state(entry, previous.values or {}, request, inp['answer_id'])
+                    await graph.aupdate_state(config, state, as_node="production_answer")
+                    snapshot = await graph.aget_state(config)
+                    state['cache_review'] = review_info(state,
+                        snapshot.config['configurable']['checkpoint_id'])
+                    _write_api_log(endpoint, thread_id, {"query": query, "cache_hit": True},
+                                   state, round(time.monotonic() - t0, 3))
+                    yield {"type": "done", "state": state}
+                    return
+        except (OSError, ValueError, KeyError):
+            inp['cache_request'] = {}  # Local version metadata unavailable: normal path.
     while True:
     while True:
         interrupted: object | None = None
         interrupted: object | None = None
         async for item in _get_graph().astream(inp, config, stream_mode=["updates", "custom"]):
         async for item in _get_graph().astream(inp, config, stream_mode=["updates", "custom"]):
@@ -247,7 +292,9 @@ async def _astream_run(thread_id: str, query: str,
             return
             return
         inp = Command(resume="确认")
         inp = Command(resume="确认")
     st = await _get_graph().aget_state(config)
     st = await _get_graph().aget_state(config)
-    state = st.values or {}
+    state = dict(st.values or {})
+    checkpoint_id = (getattr(st, 'config', None) or {}).get('configurable', {}).get('checkpoint_id')
+    state['cache_review'] = review_info(state, checkpoint_id, _answer_cache is not None)
     _write_api_log(endpoint, thread_id, {
     _write_api_log(endpoint, thread_id, {
         "query": query,
         "query": query,
         "auto_confirm": auto_confirm,
         "auto_confirm": auto_confirm,
@@ -303,7 +350,9 @@ async def _astream_resume(thread_id: str, reply: str,
                "options": intr.get("options", [])}
                "options": intr.get("options", [])}
         return
         return
     st = await _get_graph().aget_state(config)
     st = await _get_graph().aget_state(config)
-    state = st.values or {}
+    state = dict(st.values or {})
+    checkpoint_id = (getattr(st, 'config', None) or {}).get('configurable', {}).get('checkpoint_id')
+    state['cache_review'] = review_info(state, checkpoint_id, _answer_cache is not None)
     _write_api_log(endpoint, thread_id, {"reply": reply}, state,
     _write_api_log(endpoint, thread_id, {"reply": reply}, state,
                    round(time.monotonic() - t0, 3))
                    round(time.monotonic() - t0, 3))
     yield {"type": "done", "state": state}
     yield {"type": "done", "state": state}
@@ -416,6 +465,61 @@ async def resume(thread_id: str, body: ResumeRequest):
     return _answer_payload(thread_id, last_state, time.monotonic() - t0)
     return _answer_payload(thread_id, last_state, time.monotonic() - t0)
 
 
 
 
+class AnswerReviewRequest(BaseModel):
+    model_config = {"extra": "forbid"}
+    answer_id: str = Field(min_length=1, max_length=128)
+    checkpoint_id: str = Field(min_length=1, max_length=128)
+    entry_id: str | None = Field(default=None, max_length=128)
+
+
+async def _review_snapshot(thread_id, body):
+    if _answer_cache is None:
+        raise HTTPException(503, "问答缓存未启用")
+    config = _config(thread_id)
+    config['configurable']['checkpoint_id'] = body.checkpoint_id
+    snapshot = await _get_graph().aget_state(config)
+    state = dict(snapshot.values or {})
+    if (snapshot.next or state.get('answer_id') != body.answer_id
+            or (state.get('cache_request') or {}).get('scope') != digest(thread_id)):
+        raise HTTPException(409, "该答案记录不可用,请重新提问")
+    return state
+
+
+@app.post("/threads/{thread_id}/answer-cache")
+async def approve_answer(thread_id: str, body: AnswerReviewRequest):
+    state = await _review_snapshot(thread_id, body)
+    request = state.get('cache_request') or {}
+    current = release_snapshot()
+    current_key = prepare_request(thread_id, state['question'], {}, current)
+    if any(request.get(k) != current_key[k] for k in ('data_version', 'schema_version', 'policy', 'model')):
+        raise HTTPException(409, "数据已更新,请重新提问后确认")
+    try:
+        entry = await _answer_cache.approve(state)
+    except ValueError as exc:
+        raise HTTPException(409, str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(503, "缓存暂时不可用,未能确认保存,请重试") from exc
+    current_key = prepare_request(thread_id, state['question'], {}, release_snapshot())
+    if any(request.get(k) != current_key[k] for k in ('data_version', 'schema_version', 'policy', 'model')):
+        raise HTTPException(409, "数据或配置已更新,本次缓存不会用于新版本")
+    return {"status": "ok", "entry_id": entry['entry_id'], "approved_at": entry['approved_at'],
+            "message": "已确认并加入缓存"}
+
+
+@app.delete("/threads/{thread_id}/answer-cache")
+async def revoke_answer(thread_id: str, body: AnswerReviewRequest):
+    state = await _review_snapshot(thread_id, body)
+    if not body.entry_id:
+        raise HTTPException(422, "缺少缓存记录标识")
+    try:
+        await _answer_cache.revoke(state['cache_request'], body.entry_id)
+    except ValueError as exc:
+        raise HTTPException(409, str(exc)) from exc
+    except Exception as exc:
+        raise HTTPException(503, "暂时无法撤销缓存,请重试") from exc
+    return {"status": "ok", "message": "已撤销缓存"}
+
+
 @app.get("/threads/{thread_id}/history")
 @app.get("/threads/{thread_id}/history")
 async def history(thread_id: str):
 async def history(thread_id: str):
     st = await _get_graph().aget_state(_config(thread_id))
     st = await _get_graph().aget_state(_config(thread_id))

+ 4 - 0
src/step4_web/data_update.py

@@ -18,6 +18,7 @@ from step2_graph_building.config import get_metadata_template_dir, get_relation_
 from step2_graph_building.db import get_driver
 from step2_graph_building.db import get_driver
 from step2_graph_building.runtime import ROOT, MANIFEST, release_snapshot
 from step2_graph_building.runtime import ROOT, MANIFEST, release_snapshot
 from .release_retention import prune_releases
 from .release_retention import prune_releases
+from .answer_cache_retention import cleanup_answer_cache_for_release
 
 
 
 
 def template_fingerprint():
 def template_fingerprint():
@@ -167,6 +168,9 @@ def update_data(mode='auto', *, fetcher=None, assessor=None, analyzer=None, driv
                 driver.execute_query('MATCH (n:_Step2Record) WHERE n._kg_build <> $build DETACH DELETE n', build=build_id)
                 driver.execute_query('MATCH (n:_Step2Record) WHERE n._kg_build <> $build DETACH DELETE n', build=build_id)
             except Exception:
             except Exception:
                 report['warning'] = '发布成功,旧图清理失败;查询仍只读取当前版本'
                 report['warning'] = '发布成功,旧图清理失败;查询仍只读取当前版本'
+            report['answer_cache_cleanup'] = cleanup_answer_cache_for_release(build_id)
+            if report['answer_cache_cleanup'].get('warning'):
+                print(report['answer_cache_cleanup']['warning'], flush=True)
             print('数据版本发布完成', flush=True)
             print('数据版本发布完成', flush=True)
             return report
             return report
         except Exception as exc:
         except Exception as exc: