diff --git a/.doc/audit/ISSUES.md b/.doc/audit/ISSUES.md index 80a383e9..3bfe7c71 100644 --- a/.doc/audit/ISSUES.md +++ b/.doc/audit/ISSUES.md @@ -2,10 +2,12 @@ 维护规则:[问题记录与开发经验闭环](../ops/2026-09-23-issue-learning-workflow.md)。每个问题一个稳定 ID;主记录是事实与状态来源,本表为可检索投影。新发现及时登记,修复后保留记录;发布 blocker 仍见 [gap-matrix](../remediation/gap-matrix.md)。 -更新:2026-09-28。docgov 共享 map 并发读写曾在 06:38 UTC 打崩 dev 进程(resolved:dev `b9339cd2` 重建后 `/health` 200,空登录 401)。只上传文档、无文字时对话 400 已在重载后的 dev 浏览器复验(resolved,`bin/agent-server` 10:22 +0800)。历史任务 run 事件流 HTTP/2 与 502 另记,未在本轮修。激活预览前端预检与管理台密钥 form 已在 dev 浏览器复验(resolved,dev `3e534bbb`,项目 6023)。非编制人触发小队账本提交被 `uow: permission denied` 拒绝,已改为编制人代理提交并在 dev 重跑通过(resolved)。test 注册 INTERNAL:标准角色 role-guest 缺失导致 FK 23503,已补种并重启 test(goose 77,resolved)。重复用户名 400 在新代码单测通过;当前 rc 二进制仍返回 INTERNAL,随下次含本提交的 test 制品生效。管理台重置密码入口被 F4-A 误删(端点仍在),已恢复 UI(resolved)。2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。 +更新:2026-09-28。资料类名词提问被识别成模糊咨询并吃到阶段推销模板(resolved:intent-material 已合入修复与验收报告);阶段推进失败后没有助手消息(open,未修)。docgov 共享 map 并发读写曾在 06:38 UTC 打崩 dev 进程(resolved:dev `b9339cd2` 重建后 `/health` 200,空登录 401)。只上传文档、无文字时对话 400 已在重载后的 dev 浏览器复验(resolved,`bin/agent-server` 10:22 +0800)。历史任务 run 事件流 HTTP/2 与 502 另记,未在本轮修。激活预览前端预检与管理台密钥 form 已在 dev 浏览器复验(resolved,dev `3e534bbb`,项目 6023)。非编制人触发小队账本提交被 `uow: permission denied` 拒绝,已改为编制人代理提交并在 dev 重跑通过(resolved)。test 注册 INTERNAL:标准角色 role-guest 缺失导致 FK 23503,已补种并重启 test(goose 77,resolved)。重复用户名 400 在新代码单测通过;当前 rc 二进制仍返回 INTERNAL,随下次含本提交的 test 制品生效。管理台重置密码入口被 F4-A 误删(端点仍在),已恢复 UI(resolved)。2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。 | issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 | |---|---|---|---|---|---|---|---|---| +| ISSUE-20260928-material-template-reply | intent / coordinator / 说明书 / 操作视频 / 掂量 / reply_source=template / fallback_reason | 说明书、操作视频、竞品等资料名词未进知识词典,落到模糊咨询后输出阶段推销模板;对话模型失败时没有日志和 fallback 原因 | high | verifying | Cursor | 2026-09-28;test 会话 sess-p8-u33;dev 库项目 6024 工作树二进制 reply_source=kb | [主记录](../ops/short-term/2026-09-28-intent-material-fallback.md#issue-20260928-material-template-reply) | [LESSON-20260928-material-noun-not-sales-template](../ops/lessons/2026-09-28-material-noun-not-sales-template.md) | +| ISSUE-20260928-advance-stage-silent-fail | coordinator / ADVANCE_STAGE / EXPECTATION_HIT / step_03_regulatory / chat turn failed | 用户回复「1」触发阶段推进后,法规步骤失败,回合被标失败且没有助手消息,用户盲等 | high | open | Cursor | 2026-09-28;test 会话 sess-p5-u32 turn 22;本轮只登记不修 | [主记录](../ops/short-term/2026-09-28-intent-material-fallback.md#issue-20260928-advance-stage-silent-fail) | 未修,不新增经验 | | ISSUE-20260928-docgov-map-race | docgov / memDocs / concurrent map / GetDocument / 登录 500 | per-doc 锁拆分后共享 map 无互斥,不同文档并发读写 fatal,进程退出期间全部 API 500 | critical | resolved | Cursor | 2026-09-28;fatal + ECONNREFUSED;`-race` PASS;dev `b9339cd2` 重启后 `/health` 200、空登录 401(`req_e28309cc8d615ed4`)、Vite public-config 200 | [主记录](../ops/short-term/2026-09-28-docgov-shared-map-race.md#issue-20260928-docgov-map-race) | [LESSON-20260928-docgov-shared-map-race](../ops/lessons/2026-09-28-docgov-shared-map-race.md) | | ISSUE-20260928-quality-heading-drift | squad / quality_gate / 缺少必填章节 / State-of-the-Art / H3 | 调研报告章节在,只因 H3 和少空格被判缺章,受控文档不投影 | high | resolved | Cursor | 2026-09-28;dev `bd_research_1790578240073269711` project_id 6024,quality passed,doc head_snapshot_id 62 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-quality-heading-drift) | [LESSON-20260928-quality-heading-normalize](../ops/lessons/2026-09-28-quality-heading-normalize.md) | | ISSUE-20260928-data-dir-doubled | workspace / data/dev/data/dev / DataDir | 出现双层 `data/dev/data/dev/workspaces` 且只有孤立 output.md,写入点未证实 | medium | open | — | 2026-09-28;`intake_1790565513115586191` 双路径对照 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-data-dir-doubled) | 本轮不修 | diff --git a/.doc/ops/lessons/2026-09-28-material-noun-not-sales-template.md b/.doc/ops/lessons/2026-09-28-material-noun-not-sales-template.md new file mode 100644 index 00000000..a522afad --- /dev/null +++ b/.doc/ops/lessons/2026-09-28-material-noun-not-sales-template.md @@ -0,0 +1,20 @@ +--- +lesson_id: LESSON-20260928-material-noun-not-sales-template +lesson_state: validated +owner: Cursor +reviewer: Cursor(自审) +last_reviewed: 2026-09-28 +reviewed_revision: 8f77053f5169502eaf3e4e4135a082e8914f3704 +source_issues: [ISSUE-20260928-material-template-reply] +keywords: [intent, 说明书, 操作视频, 竞品, AMBIGUOUS_FALLBACK, 掂量, fallback_reason] +--- + +# 资料名词不要掉进阶段推销模板 + +- 触发条件:用户用名词短语索取说明书、视频、彩页、手册、上一代或竞品资料,句子里没有「生成/编写/撰写」。 +- 机制与来源:低置信保底会进模糊咨询;模糊咨询原先对非问句直接输出阶段对照。对话模型建目录或执行失败时若只返回空字符串,模板会原样留下,日志里看不到原因。见 [任务](../short-term/2026-09-28-intent-material-fallback.md)。 +- 开发检查:资料获取走知识检索,未命中再走对话模型;犹豫征询才保留阶段对照。模型早退必须打日志,并把 `fallback_reason` 写入 `reply_source=template` 的 metadata。写动词不要误判成检索。 +- 验证方式:原始输入单测不得再是 `AMBIGUOUS_FALLBACK`;无模型时回复不得含「掂量」;真实产品回合的 `reply_source` 为 `pi-agent` 或 `kb`。 +- 适用边界:适用于「把已有资料找出来」。用户明确要编写新文档时仍走任务触发。阶段推进失败后没有助手消息是另一问题(ISSUE-20260928-advance-stage-silent-fail),本条不覆盖。 +- 防护落实:manual,另有 intent / coordinator / chat 单测。2026-09-28 在 dev 库、dev runner、工作树二进制上确认 `reply_source=kb`(项目 6024)。:8080 二进制尚未替换。 +- 修订与失效:若产品改为资料请求一律进小队任务,本条的检索分流不再适用,保留历史。 diff --git a/.doc/ops/lessons/INDEX.md b/.doc/ops/lessons/INDEX.md index 48545ae6..af891b37 100644 --- a/.doc/ops/lessons/INDEX.md +++ b/.doc/ops/lessons/INDEX.md @@ -4,6 +4,7 @@ | lesson_id | 触发条件 / 关键词 | 注意事项 | lesson_state | 主记录 / 最近复核 | |---|---|---|---|---| +| LESSON-20260928-material-noun-not-sales-template | 说明书、操作视频、竞品、掂量、AMBIGUOUS_FALLBACK、fallback_reason | 资料名词走检索或对话模型;阶段推销只留给犹豫征询;模型早退要留原因 | validated | [资料名词不吃推销模板](2026-09-28-material-noun-not-sales-template.md);2026-09-28 | | LESSON-20260928-docgov-shared-map-race | docgov、memDocs、per-doc 锁、concurrent map、登录 500 | 拆锁后共享 map 仍要单独互斥;锁不可重入;全站 500 先看进程是否退出 | validated | [共享 map 互斥](2026-09-28-docgov-shared-map-race.md);2026-09-28 | | LESSON-20260928-quality-heading-normalize | 缺少必填章节、State-of-the-Art、H3、质量门禁、AutoRepair | 标题比对去空白并跨 H2–H4;层级漂移不扣分;真实缺章仍失败 | candidate | [章节标题归一化](2026-09-28-quality-heading-normalize.md);2026-09-28 | | LESSON-20260928-task-write-verb-visible-refusal | 帮我定、制定、ClarificationQuestion、前置条件、quality_failed | 写动词覆盖制定与帮我定;拒绝和澄清进对话;质量失败不写宿主机路径 | candidate | [写动词与可见拒绝](2026-09-28-task-write-verb-visible-refusal.md);2026-09-28 | diff --git a/.doc/ops/short-term/2026-09-28-intent-material-fallback.md b/.doc/ops/short-term/2026-09-28-intent-material-fallback.md new file mode 100644 index 00000000..fba3833c --- /dev/null +++ b/.doc/ops/short-term/2026-09-28-intent-material-fallback.md @@ -0,0 +1,88 @@ +--- +title: "资料类提问不再落入阶段推销模板" +status: blocked +created: 2026-09-28 +owner: Cursor +task_id: intent-material-fallback-2026-09-28 +policy_revision: agent-governance-2026-09-23-fav-constraint-restored @ 434d542c3cbdbeac38b9c0e547edf75160acf577 +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +remote_sync: not_requested +updated: 2026-09-28 +--- + +# 资料类提问不再落入阶段推销模板 + +## 范围与授权 + +用户要求:test 上 zhangqizhe 在「全自动冲击波治疗仪1」询问说明书、操作视频、竞品资料时,不要再回复「完全理解您想再掂量一下…V0.5」模板;并核对 wanjiahao 发送「1」的失败是否同一类问题。 + +授权:只改代码并合入 local dev,dev 真实链路验证后再谈 test 晋升。test 目录属主与 `PI_AGENT_RUNNER_URL` 本轮不改。 + +允许写路径:`internal/intent`、`internal/coordinator`、`internal/chat`、`.doc`。工作树已快进到 `dev` `8889011e5b6bd35a1d992ae5aec3f1c1c8bdae31`(原始基线 `8f77053f5169502eaf3e4e4135a082e8914f3704`)。分支 `fix/intent-material-fallback-2026-09-28`。mutex:`intent+coordinator`。 + +非目标:不新增第 19 类意图;不改 ADVANCE_STAGE 失败后的静默回合;不晋升 test/main;不 push。 + +## 问题登记与经验检索 + +检索词:`掂量`、`AMBIGUOUS_FALLBACK`、`reply_source`、`说明书`、`ADVANCE_STAGE`。台账与 `.doc/ops/lessons` 无同机制命中。本任务登记: + +- [ISSUE-20260928-material-template-reply](#issue-20260928-material-template-reply)(本任务修复) +- [ISSUE-20260928-advance-stage-silent-fail](#issue-20260928-advance-stage-silent-fail)(只登记) + +经验:[LESSON-20260928-material-noun-not-sales-template](../lessons/2026-09-28-material-noun-not-sales-template.md)。 + +## 现象 + +test 库 `sess-p8-u33`(项目 8,用户 33)中「上一代产品说明书」「竞品的操作视频」等助手消息 `reply_source=template`,正文为阶段推销,多条内容哈希相同。识别结果为 `CASUAL_CHAT` / `AMBIGUOUS_FALLBACK`,置信度 0.2。 + +`sess-p5-u32` 在 2026-09-28 01:29:46Z 的「1」(turn 22)`status=failed`,没有助手消息。约 14 分钟后第二条「1」(turn 23)完成阶段推进。 + +## 根因分析 + +已证实(问题 A): + +1. `matchKnowledgeQuery` 没有说明书、操作视频、彩页、竞品等资料词,输入掉进 `CASUAL_CHAT` 保底(置信度 0.2),再被收成 `AMBIGUOUS_FALLBACK`。 +2. `handleAmbiguousConsult` 对非问句直接拼「掂量 / V0.5」模板。问句标记才会改去闲聊。 +3. 对话模型早退不打日志。test 进程属主 weichen,`data/test/workspaces/dialogue` 属主 root,`MkdirAll` 返回 EACCES 后回复保持模板。test 服务的 `PI_AGENT_RUNNER_URL` 指向 dev runner `18865`(test runner 是 `18866`)。环境问题留到 dev 验证之后;代码侧补上失败原因。 + +已证实(问题 B,不修):turn 22 意图 `ADVANCE_STAGE` / `EXPECTATION_HIT`。工作区 `stage_V0.5_feasibility_1790558986793391446` 的 `step_03_regulatory` 失败且输出为空,回合随后没有助手消息。与推销模板不是同一条失败路径。 + +## 同类问题与影响范围 + +| 关联场景/入口 | 与原例共用的机制 | 检查方法与证据 | 判定及影响 | +|---|---|---|---| +| 犹豫征询(「你觉得呢」) | 同一模糊分支 | 单测仍要求保留掂量对照 | 保留,征询判断早于问句改道 | +| 编写/撰写手册 | 资料词与写动词重叠 | 「请撰写详尽合规手册」不得标 materials | 写动词仍走任务触发 | +| 标杆案例 | 同一意图 `QUERY_SIMILAR_CASES` | 无 `query_topic=materials` 时仍走原案例模板 | 资料请求用 slot 分流,避免伪造案例 | +| 阶段推进失败无回复 | 不是意图词典 | turn 22 无助手行 | 另立 issue,本轮不改 | + +## 修复 + +- 资料词命中 `QUERY_SIMILAR_CASES`,`query_topic=materials`,置信度 0.82。有知识库命中则 `reply_source=kb`;否则对话模型;再失败则中性追问并写 `fallback_reason`。 +- 模糊分支里,非犹豫、非空输入不再使用阶段推销模板。 +- `generateDialogueReply` 早退打 `dialogue_reply_fallback`,原因写入 `CoordinationResult.ReplyFallbackReason`;`reply_source=template` 时落入消息 metadata。 + +## 五态 + +- review_state: pending +- integration_state: blocked(2026-09-28 20:45 工作树在 `8889011e`。已关掉工作树或分支已消失、且 scope 为 `.` 的夜间 integrate 空锁。`task register` 仍被拒:`same_scope`,持有者 `quality-gate-heading-deploy-2026-09-28`,overlap `5a90581e-5a70-42e2-9745-8f04ac0ec11f`。该分支还有未进 dev 的文档提交 `5c0a84f7`。`nightly-advance-land-2026-09-28` 占 `.doc` 且状态 in_progress。`controlled-projection-project-binding-2026-09-28` 占 `internal/coordinator`,约 20:35 仍有新提交) +- deployment: not_required(:8080 仍是基线二进制 pid 1002830。真实链路打在工作树二进制 `127.0.0.1:18080`,已停止) +- cleanup: pending +- remote_sync: not_requested + +## ISSUE-20260928-material-template-reply + +- issue_state: verifying +- severity: high +- owner: Cursor +- 验收:单测三条原始输入为 `QUERY_SIMILAR_CASES` / `materials` / 0.82。dev 库真实回合(项目 6024,会话 `sess-intent-material-probe-2`)`reply_source=kb`,不含「掂量」。报告见 [测试报告](../../test-reports/2026-09-28-intent-material-fallback-test-report.md)。:8080 尚未换成这版二进制。 + +## ISSUE-20260928-advance-stage-silent-fail + +- issue_state: open +- severity: high +- owner: Cursor +- 下一步:独立授权后再修「小队步骤失败必须落一条助手失败说明」。本记录不把该问题标成已修。 diff --git a/.doc/test-reports/2026-09-28-intent-material-fallback-test-report.md b/.doc/test-reports/2026-09-28-intent-material-fallback-test-report.md new file mode 100644 index 00000000..00303430 --- /dev/null +++ b/.doc/test-reports/2026-09-28-intent-material-fallback-test-report.md @@ -0,0 +1,71 @@ +# 资料类提问意图与回复 测试报告 + +## 一、 测试背景 (Background) + +test 会话里「上一代产品说明书」「竞品的操作视频」被收成模糊咨询,助手回复阶段推销模板(`reply_source=template`)。本轮在 `fix/intent-material-fallback-2026-09-28`(基线 `8f77053f`)把资料名词改到知识检索,未命中再走对话模型,并让模型早退留下 `fallback_reason`。 + +## 二、 测试目标 (Test Objectives) + +- 三条原始资料输入不再是 `AMBIGUOUS_FALLBACK`,slot 为 `query_topic=materials`,置信度 ≥ 0.75。 +- 无对话模型时,非犹豫输入不得出现「掂量」模板;犹豫输入仍保留对照。 +- `reply_source=template` 时 metadata 带 `fallback_reason`。 +- dev 数据库与 dev runner 上的真实对话,`reply_source` 为 `kb` 或 `pi-agent`,正文不含阶段推销。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux。Go 模块 `chat-agent-system`。工作树 `.worktrees/intent-material-fallback-2026-09-28`。 +- 单测:`go test ./internal/intent/ ./internal/coordinator/ ./internal/chat/ -count=1`。 +- 真实链路:用工作树源码构建的进程,环境变量取自正在运行的 dev `agent-server`(`ENV=dev`,`DATA_DIR=./data/dev`,`PI_AGENT_RUNNER_URL=http://127.0.0.1:18865`,知识库在线)。监听 `127.0.0.1:18080`,避免替换尚未合入的 `:8080` 二进制。账号为 dev 管理员,项目 6024「吞咽电刺激」(dev 库没有 test 上的项目 8)。 +- 预存失败对照:同一语义记忆用例在未改动的主目录 `dev` `8f77053f` 上同样失败。 + +## 四、 预期结果 (Expected Results) + +- 资料名词:`QUERY_SIMILAR_CASES`,`materials`,置信度 0.82。 +- 有知识库命中:`reply_source=kb`,正文引用材料,不含「掂量」。 +- 撰写类输入不标成资料检索。 +- 犹豫输入在模型不可用时仍含阶段对照,并带 fallback 原因。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 单测 + +- **输入**:`go test ./internal/intent/ ./internal/coordinator/ ./internal/chat/ -count=1 -timeout 240s` +- **实际输出**:intent `ok`(0.072s),coordinator `ok`(0.014s)。chat 仅 `TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord` 失败(2.01s,`session_test.go:556`)。同一命令在主目录未改代码上同样失败(2.02s)。本改动的资料、模板和 `fallback_reason` 用例通过。 +- **评价**:本任务用例 PASS。语义记忆索引用例为基线既有失败,不作为本改动回归。 +- **耗时**:约 2.5s。 + +### 真实对话(项目 6024,新会话 `sess-intent-material-probe-2`) + +- **输入**:`POST /api/v1/chat`,消息「上一代产品说明书」。 +- **实际输出**:HTTP 200,8.46s。`top_intent=QUERY_SIMILAR_CASES`,置信度 0.82,`filled_slots.query_topic=materials`。`reply_source=kb`。正文以「关于「吞咽电刺激」里您要的「上一代产品说明书」,检索到这些材料」开头,不含「掂量」。`chat_messages.metadata.reply_source=kb`。 +- **评价**:PASS。 +- **耗时**:8.46s。证据 [probe-clean.json](assets/2026-09-28-intent-material-fallback/probe-clean.json)。 + +### 同一进程上的另外两条(会话 `sess-intent-material-probe`) + +- **输入**:「类似的全自动产品的说明书」(11.19s)、「竞品的操作视频」(9.72s)。该会话在更早一次失败请求里记下了项目名「全自动冲击波治疗仪1」,所以引用抬头沿用了这个名字;意图与来源不受影响。 +- **实际输出**:两条均为 HTTP 200,`reply_source=kb`,`has_sales_template=false`。库中助手行 `reply_source=kb`。 +- **评价**:PASS(路由与来源)。项目名抬头是探测会话复用,不是资料分流缺陷。 +- **耗时**:见 [probe.json](assets/2026-09-28-intent-material-fallback/probe.json)。 + +### 官方 :8080 + +- **输入**:同一「上一代产品说明书」打到正在运行的 `bin/agent-server`(pid 1002830,基线二进制)。 +- **实际输出**:项目 8 在 dev 库不存在,请求 500。项目 6024 的既有日志仍把低置信输入记成 `AMBIGUOUS_FALLBACK`(03:26:06Z)。本二进制尚未替换 :8080。 +- **评价**:未在 :8080 上复验新代码。合入并重启后再记。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 套件 | 结果 | +|---|---| +| `go test ./internal/intent/` | PASS | +| `go test ./internal/coordinator/` | PASS | +| `go test ./internal/chat/` | 1 个基线失败(语义记忆异步索引),其余通过;本任务新增用例通过 | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +资料分流在 dev 库、dev runner、工作树二进制上达到「命中知识库则 `reply_source=kb`,不再输出阶段推销模板」。对话模型早退的日志与 `fallback_reason` 有单测;这次真实回合走了知识库,没有触发早退。 + +尚未合入 local dev:`task register` 被硬重叠拒绝,持有者 `agent-reply-llm-narrative-2026-09-28`(`same_scope`,scopes `.doc` 与 `internal`)。:8080 仍是旧二进制。test 目录属主与 runner 端口不在本轮。 + +阶段推进失败后没有助手消息另记 ISSUE-20260928-advance-stage-silent-fail,本报告不把它记成已修。 diff --git a/.doc/test-reports/assets/2026-09-28-intent-material-fallback/probe-clean.json b/.doc/test-reports/assets/2026-09-28-intent-material-fallback/probe-clean.json new file mode 100644 index 00000000..32b2b936 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-intent-material-fallback/probe-clean.json @@ -0,0 +1,19 @@ +{ + "session_id": "sess-intent-material-probe-2", + "project_id": 6024, + "project_name": "吞咽电刺激", + "server": "worktree binary on 127.0.0.1:18080", + "database": "agent_system_dev", + "runner": "http://127.0.0.1:18865", + "seconds": 8.46, + "top_intent": { + "intent": "QUERY_SIMILAR_CASES", + "confidence": 0.82, + "branch": "NEW_INTENT_PREEMPT", + "filled_slots": {"query_topic": "materials"} + }, + "reply_source": "kb", + "fallback_reason": null, + "has_sales_template": false, + "reply_head": "关于「吞咽电刺激」里您要的「上一代产品说明书」,检索到这些材料:" +} diff --git a/.doc/test-reports/assets/2026-09-28-intent-material-fallback/probe.json b/.doc/test-reports/assets/2026-09-28-intent-material-fallback/probe.json new file mode 100644 index 00000000..4d4ac546 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-intent-material-fallback/probe.json @@ -0,0 +1,92 @@ +[ + { + "input": "上一代产品说明书", + "http": 200, + "seconds": 7.22, + "intent_keys": [ + "branch", + "candidates", + "context_snapshot_id", + "intent_frame_v2", + "plan_proposal", + "plan_shadow", + "ranked_frames", + "raw_input", + "requires_clarifying", + "shadow_decision_hint", + "shadow_dialogue_act", + "shadow_margin", + "shadow_score", + "top_intent" + ], + "intent": null, + "branch": "NEW_INTENT_PREEMPT", + "confidence": null, + "slots": null, + "reply_source": "kb", + "fallback_reason": null, + "has_sales_template": false, + "reply_head": "关于「全自动冲击波治疗仪1」里您要的「上一代产品说明书」,检索到这些材料:\n\n1. **中华人民共和国医药行业标准** 〔YY/T0149-2006 §8.2 p.12〕\n - 位置: 气压弹道式体外压力波治疗设备 / 8.2 8.2 使用说明书\n - 要点: 使用说明书至少应包括下述内容:\na)制造商名称、商标和地址;\nb)产品名称、规格型号;\nc) 产品特点、用途、适用范围和主要性能与主要技术参数;\nd) 安装方法、操作使用、保养维修、安全注意事项等详细说明。\n2. **中华人民共和国医药行业标准** 〔YY/T0466.1-2009 §6.15 p.9〕\n - 位置: Interferential current therapy instrument / 6.15 6.15 使用说明书\n - 要点: 查看使用说明书,应符合 5.15 的要求。\n3. **中华人民共和国", + "error_code": null + }, + { + "input": "类似的全自动产品的说明书", + "http": 200, + "seconds": 11.19, + "intent_keys": [ + "branch", + "candidates", + "context_snapshot_id", + "intent_frame_v2", + "plan_proposal", + "plan_shadow", + "ranked_frames", + "raw_input", + "requires_clarifying", + "shadow_decision_hint", + "shadow_dialogue_act", + "shadow_margin", + "shadow_score", + "top_intent" + ], + "intent": null, + "branch": "NEW_INTENT_PREEMPT", + "confidence": null, + "slots": null, + "reply_source": "kb", + "fallback_reason": null, + "has_sales_template": false, + "reply_head": "关于「全自动冲击波治疗仪1」里您要的「类似的全自动产品的说明书」,检索到这些材料:\n\n1. **中华人民共和国医药行业标准** 〔YY/T0149-2006 §8.2 p.12〕\n - 位置: 气压弹道式体外压力波治疗设备 / 8.2 8.2 使用说明书\n - 要点: 使用说明书至少应包括下述内容:\na)制造商名称、商标和地址;\nb)产品名称、规格型号;\nc) 产品特点、用途、适用范围和主要性能与主要技术参数;\nd) 安装方法、操作使用、保养维修、安全注意事项等详细说明。\n2. **中华人民共和国国家标准** 〔GB/T1.1-2009 §201.7.9.2 p.13〕\n - 位置: 医用电气设备 第2-17部分:自动控制式近距离治疗后装设备的基本安全和基本性能专用要求 / 201.7.9.2 201.7.9.2 使用说明书\n - 要点: 201.7.9.2 使用说明书\n3", + "error_code": null + }, + { + "input": "竞品的操作视频", + "http": 200, + "seconds": 9.72, + "intent_keys": [ + "branch", + "candidates", + "context_snapshot_id", + "intent_frame_v2", + "plan_proposal", + "plan_shadow", + "ranked_frames", + "raw_input", + "requires_clarifying", + "shadow_decision_hint", + "shadow_dialogue_act", + "shadow_margin", + "shadow_score", + "top_intent" + ], + "intent": null, + "branch": "NEW_INTENT_PREEMPT", + "confidence": null, + "slots": null, + "reply_source": "kb", + "fallback_reason": null, + "has_sales_template": false, + "reply_head": "关于「全自动冲击波治疗仪1」里您要的「竞品的操作视频」,检索到这些材料:\n\n1. **网站首页** 〔网站首页 §(四十三)冲击波治疗仪:主要由主机、支臂和控制手柄组成。通过刺激外周神经、骨骼、肌肉、关节部位,对人体病灶部位进行治疗。用于外周神经、节功能的改善与辅助治疗。不用于心脏、瘫痪部位、前列腺、阴茎等敏感部位。分类编码为:09-04。〕\n - 位置: 2022年第三次医疗器械产品分类界定结果汇总 / (四十三)冲击波治疗仪:主要由主机、支臂和控制手柄组成。通过刺激外周神经、骨骼、肌肉、关节部位,对人体病灶部位进行治疗。用于外周神经、节功能的改善与辅助治疗。不用于心脏、瘫痪部位、前列腺、阴茎等敏感部位。分类编码为:09-04。 冲击波治疗仪:主要由主机、支臂和控制手柄组成。通过刺激外周神经、骨骼、肌肉、关节部位,对人体病灶部位进行治疗。用于外周神经、节功能的改善与辅助治疗。不用于心", + "error_code": null + } +] \ No newline at end of file diff --git a/internal/chat/session.go b/internal/chat/session.go index 3cdb42ba..88383d1a 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -575,6 +575,11 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str asstMeta := map[string]any{ "reply_source": replySrc, } + if replySrc == coordinator.ReplySourceTemplate { + if reason := strings.TrimSpace(coordRes.ReplyFallbackReason); reason != "" { + asstMeta["fallback_reason"] = reason + } + } if s.ProjectID > 0 { asstMeta["project_id"] = s.ProjectID } diff --git a/internal/chat/session_turn_intent_test.go b/internal/chat/session_turn_intent_test.go index d9b8f7d8..33c8a8d6 100644 --- a/internal/chat/session_turn_intent_test.go +++ b/internal/chat/session_turn_intent_test.go @@ -2,6 +2,7 @@ package chat_test import ( "context" + "strings" "testing" "chat-agent-system/internal/bizdev" @@ -86,3 +87,51 @@ func TestChatSession_AssistantMetadataTurnIntentAndReplySource(t *testing.T) { t.Errorf("expected reply_source to be non-empty string in assistant metadata, got %v", asstMsg.Metadata["reply_source"]) } } + +func TestChatSession_MaterialFallbackReasonPersisted(t *testing.T) { + rec := intent.NewRecognizer() + engine := coordinator.NewCoordinatorEngine(nil) + userCtx := &coordinator.UserProjectContext{ + SnapshotID: "sess_ctx_material", + Project: coordinator.ProjectInfo{ + Name: "全自动冲击波治疗仪1", + CurrentStage: bizdev.StageV01_Triage, + }, + } + cfg := &config.Config{DataDir: t.TempDir()} + svc := bizdev.MustNewService(t, cfg) + mgr := bizdev.Snapshot(svc).Ctx + + sess := chat.NewSession("sess_test_material_fallback", userCtx, rec, engine) + sess.CtxMgr = mgr + + ctx := context.Background() + _, err := sess.SendMessage(ctx, "上一代产品说明书") + if err != nil { + t.Fatalf("SendMessage failed: %v", err) + } + history, err := mgr.GetChatHistory(ctx, "sess_test_material_fallback", 10, 0) + if err != nil { + t.Fatalf("GetChatHistory failed: %v", err) + } + var asst *ctxmgr.ChatMessageRecord + for i := range history { + if history[i].Role == "assistant" { + asst = &history[i] + break + } + } + if asst == nil { + t.Fatal("assistant message missing") + } + if strings.Contains(asst.Content, "掂量") { + t.Fatalf("persisted template sales pitch: %s", asst.Content) + } + reason, _ := asst.Metadata["fallback_reason"].(string) + if reason == "" { + t.Fatalf("fallback_reason missing in metadata: %#v", asst.Metadata) + } + if src, _ := asst.Metadata["reply_source"].(string); src != coordinator.ReplySourceTemplate { + t.Fatalf("reply_source=%q", src) + } +} diff --git a/internal/coordinator/action_planner.go b/internal/coordinator/action_planner.go index a5bfd8df..71a3e078 100644 --- a/internal/coordinator/action_planner.go +++ b/internal/coordinator/action_planner.go @@ -139,6 +139,9 @@ func (e *CoordinatorEngine) dispatchIntentHandler( case intent.IntentQueryRegulations: return e.handleQueryRegulations(ctx, cand, userCtx) case intent.IntentQuerySimilarCases: + if cand.FilledSlots[intent.SlotQueryTopic] == "materials" { + return e.handleQueryMaterials(ctx, cand, userCtx, rawInput) + } return e.handleQuerySimilarCases(ctx, cand, userCtx) case intent.IntentQueryUserRights: return e.handleQueryUserRights(ctx, cand, userCtx) diff --git a/internal/coordinator/dialogue_llm.go b/internal/coordinator/dialogue_llm.go index 1d8c9cff..ab8db990 100644 --- a/internal/coordinator/dialogue_llm.go +++ b/internal/coordinator/dialogue_llm.go @@ -3,6 +3,7 @@ package coordinator import ( "context" "fmt" + "log/slog" "os" "path/filepath" "strconv" @@ -17,6 +18,7 @@ import ( const ( ReplySourcePiAgent = "pi-agent" ReplySourceTemplate = "template" + ReplySourceKB = "kb" dialogueHistoryMax = 8 dialogueTimeout = 3 * time.Minute ) @@ -27,14 +29,18 @@ type DialogueTurn struct { Content string `json:"content"` } -// generateDialogueReply 通过 pi-agent 生成参谋自然语言答复;失败返回空串由调用方回退模板。 +// generateDialogueReply 通过 pi-agent 生成参谋自然语言答复。 +// 失败时 reply 为空,fallbackReason 说明早退原因,供调用方回退并写入 metadata。 func (e *CoordinatorEngine) generateDialogueReply( ctx context.Context, userInput string, userCtx *UserProjectContext, -) (reply string, source string) { - if e == nil || e.dialogueBackend == nil || strings.TrimSpace(userInput) == "" { - return "", ReplySourceTemplate +) (reply string, source string, fallbackReason string) { + if strings.TrimSpace(userInput) == "" { + return dialogueFallback(ctx, "empty_input", nil) + } + if e == nil || e.dialogueBackend == nil { + return dialogueFallback(ctx, "backend_nil", nil) } if userCtx != nil { @@ -79,9 +85,12 @@ func (e *CoordinatorEngine) generateDialogueReply( content, reason := e.runDialogueBackend(ctx, userCtx, prompt, instructions, "dialogue_generating", "正在生成项目助手回复...") if reason != "" || strings.TrimSpace(content) == "" { - return "", ReplySourceTemplate + if reason == "" { + reason = "empty_reply" + } + return dialogueFallback(ctx, reason, nil) } - return content, ReplySourcePiAgent + return strings.TrimSpace(content), ReplySourcePiAgent, "" } // runDialogueBackend 在独立工作目录执行一轮 reply.md 生成。失败时 content 为空,reason 供观测。 @@ -159,6 +168,15 @@ func (e *CoordinatorEngine) runDialogueBackend( return content, "" } +func dialogueFallback(ctx context.Context, reason string, err error) (string, string, string) { + if err != nil { + slog.WarnContext(ctx, "dialogue_reply_fallback", "reason", reason, "err", err.Error()) + } else { + slog.WarnContext(ctx, "dialogue_reply_fallback", "reason", reason) + } + return "", ReplySourceTemplate, reason +} + func buildDialoguePrompt(userInput string, userCtx *UserProjectContext) string { composer := NewMemoryComposer() return composer.ComposeDialoguePrompt(userInput, userCtx) diff --git a/internal/coordinator/dialogue_llm_test.go b/internal/coordinator/dialogue_llm_test.go index 77fa4034..7600e483 100644 --- a/internal/coordinator/dialogue_llm_test.go +++ b/internal/coordinator/dialogue_llm_test.go @@ -394,6 +394,91 @@ func TestDialogueLLM_AmbiguousSmalltalkDoesNotUseStageSalesTemplate(t *testing.T } } +func TestDialogueLLM_MaterialNounDoesNotUseStageSalesTemplate(t *testing.T) { + backend := &scriptedDialogueBackend{fail: true} + engine := NewCoordinatorEngineWithDialogue(nil, backend, t.TempDir()) + userCtx := &UserProjectContext{ + Project: ProjectInfo{Name: "全自动冲击波治疗仪1", CurrentStage: maturity.StageV01_Triage}, + } + rec := &intent.IntentRecognitionResult{ + RawInput: "上一代产品说明书", + Branch: intent.BranchAmbiguousFallback, + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentCasualChat, + Confidence: 0.2, + Branch: intent.BranchAmbiguousFallback, + }, + } + res, err := engine.ProcessIntent(context.Background(), rec, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if strings.Contains(res.NaturalReply, "掂量") || strings.Contains(res.NaturalReply, "V0.5") { + t.Fatalf("noun phrase must not use stage-sales template, got: %s", res.NaturalReply) + } + if !strings.Contains(res.NaturalReply, "上一代产品说明书") { + t.Fatalf("expected the user topic echoed, got: %s", res.NaturalReply) + } + if res.ReplySource != ReplySourceTemplate || res.ReplyFallbackReason == "" { + t.Fatalf("source=%q reason=%q", res.ReplySource, res.ReplyFallbackReason) + } +} + +func TestDialogueLLM_MaterialQueryUsesBackendWhenKBMisses(t *testing.T) { + backend := &scriptedDialogueBackend{reply: "上一代冲击波治疗仪说明书的公开要点:聚焦式、机械臂定位,操作视频需向厂商索取。"} + engine := NewCoordinatorEngineWithDialogue(nil, backend, t.TempDir()) + userCtx := &UserProjectContext{ + Project: ProjectInfo{Name: "全自动冲击波治疗仪1", CurrentStage: maturity.StageV01_Triage}, + } + rec := &intent.IntentRecognitionResult{ + RawInput: "竞品的操作视频", + Branch: intent.BranchNewIntentPreempt, + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentQuerySimilarCases, + Confidence: 0.82, + Branch: intent.BranchNewIntentPreempt, + FilledSlots: map[string]string{intent.SlotQueryTopic: "materials"}, + }, + } + res, err := engine.ProcessIntent(context.Background(), rec, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if res.ReplySource != ReplySourcePiAgent || !strings.Contains(res.NaturalReply, "操作视频") { + t.Fatalf("source=%q reply=%s", res.ReplySource, res.NaturalReply) + } + if strings.Contains(res.NaturalReply, "掂量") { + t.Fatalf("material query reused stage-sales template: %s", res.NaturalReply) + } +} + +func TestDialogueLLM_ConsultativeStillUsesStageTemplate(t *testing.T) { + backend := &scriptedDialogueBackend{fail: true} + engine := NewCoordinatorEngineWithDialogue(nil, backend, t.TempDir()) + userCtx := &UserProjectContext{ + Project: ProjectInfo{Name: "全自动冲击波治疗仪1", CurrentStage: maturity.StageV01_Triage}, + } + rec := &intent.IntentRecognitionResult{ + RawInput: "你觉得呢", + Branch: intent.BranchAmbiguousFallback, + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentCasualChat, + Confidence: 0.65, + Branch: intent.BranchAmbiguousFallback, + }, + } + res, err := engine.ProcessIntent(context.Background(), rec, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if !strings.Contains(res.NaturalReply, "掂量") { + t.Fatalf("consultative input should keep the stage contrast, got: %s", res.NaturalReply) + } + if res.ReplyFallbackReason == "" { + t.Fatal("expected fallback reason when dialogue backend fails") + } +} + type stubRealtimeSearcher struct { markdown string err error diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index c8ecc344..1d515966 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -55,7 +55,8 @@ type CoordinationResult struct { InteractiveCard *intent.InteractiveCardPayload `json:"interactive_card,omitempty"` NextExpectation *intent.PendingExpectation `json:"next_expectation,omitempty"` ClearExpectation bool `json:"clear_expectation,omitempty"` - ReplySource string `json:"reply_source,omitempty"` // pi-agent | template + ReplySource string `json:"reply_source,omitempty"` // pi-agent | kb | template + ReplyFallbackReason string `json:"reply_fallback_reason,omitempty"` PendingAction *PendingAction `json:"-"` // Task projection for clickable document entry (sync TRIGGER_TASK / adhoc). TaskStatus string `json:"task_status,omitempty"` @@ -390,7 +391,11 @@ func (e *CoordinatorEngine) ProcessIntent( coordRes, coordErr = e.handleQueryRegulations(ctx, top, userCtx) case intent.IntentQuerySimilarCases: - coordRes, coordErr = e.handleQuerySimilarCases(ctx, top, userCtx) + if top.FilledSlots[intent.SlotQueryTopic] == "materials" { + coordRes, coordErr = e.handleQueryMaterials(ctx, top, userCtx, recRes.RawInput) + } else { + coordRes, coordErr = e.handleQuerySimilarCases(ctx, top, userCtx) + } case intent.IntentQueryUserRights: coordRes, coordErr = e.handleQueryUserRights(ctx, top, userCtx) diff --git a/internal/coordinator/engine_consult.go b/internal/coordinator/engine_consult.go index 8946bccb..2f1af3f6 100644 --- a/internal/coordinator/engine_consult.go +++ b/internal/coordinator/engine_consult.go @@ -39,11 +39,25 @@ func (e *CoordinatorEngine) handleAmbiguousConsult(ctx context.Context, cand *in return e.handleQueryProgress(ctx, cand, userCtx, rawInput) } + // 犹豫/征询优先于问句标记,避免「你觉得呢」被闲聊分流吃掉阶段对照。 + if strings.TrimSpace(rawInput) != "" && intent.IsConsultativeOrHesitant(rawInput) { + return e.replyConsultativeAmbiguous(ctx, cand, userCtx, rawInput, currentStage, projName) + } + // 通用非业务问句不得吃阶段推销模板;改道闲聊处理器(LLM 优先,失败回退问候模板)。 if intent.IsSmallTalkOrGeneralQuestion(rawInput) { return e.handleCasualChat(ctx, userCtx, rawInput) } + // 资料名、名词短语等并非犹豫征询:先走对话模型,失败时用中性追问,不用阶段推销模板。 + if strings.TrimSpace(rawInput) != "" { + return e.handleNonConsultativeAmbiguous(ctx, cand, userCtx, rawInput, projName) + } + + return e.replyConsultativeAmbiguous(ctx, cand, userCtx, rawInput, currentStage, projName) +} + +func (e *CoordinatorEngine) replyConsultativeAmbiguous(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string, currentStage maturity.MaturityStage, projName string) (*CoordinationResult, error) { var sb strings.Builder sb.WriteString(fmt.Sprintf("完全理解您想再掂量一下。针对「%s」目前的节点,我帮您快速对照一下:\n\n", projName)) @@ -62,9 +76,12 @@ func (e *CoordinatorEngine) handleAmbiguousConsult(ctx context.Context, cand *in reply := sb.String() source := ReplySourceTemplate - if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { + fallbackReason := "" + if llmReply, llmSource, llmReason := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { reply = llmReply source = llmSource + } else { + fallbackReason = llmReason } nextExp := BuildNextExpectation(currentStage, projName) @@ -73,17 +90,56 @@ func (e *CoordinatorEngine) handleAmbiguousConsult(ctx context.Context, cand *in userCtx.applyPending(nextExp) return &CoordinationResult{ - Status: StatusAccepted, - Intent: cand.Intent, - Branch: intent.BranchAmbiguousFallback, - NaturalReply: reply, - AdvisorBrief: userCtx.LatestAdvisorBrief, - UpdatedContext: userCtx, - NextExpectation: nextExp, - ReplySource: source, + Status: StatusAccepted, + Intent: cand.Intent, + Branch: intent.BranchAmbiguousFallback, + NaturalReply: reply, + AdvisorBrief: userCtx.LatestAdvisorBrief, + UpdatedContext: userCtx, + NextExpectation: nextExp, + ReplySource: source, + ReplyFallbackReason: fallbackReason, }, nil } +func (e *CoordinatorEngine) handleNonConsultativeAmbiguous(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput, projName string) (*CoordinationResult, error) { + reply := neutralUnmatchedReply(rawInput, projName) + source := ReplySourceTemplate + fallbackReason := "" + if llmReply, llmSource, llmReason := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { + reply = llmReply + source = llmSource + } else { + fallbackReason = llmReason + } + nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, projName) + reply, nextExp = maybeAttachUpgradeHintWithEngine(e, ctx, rawInput, userCtx, reply, nextExp) + userCtx.applyPending(nextExp) + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + Branch: intent.BranchAmbiguousFallback, + NaturalReply: reply, + AdvisorBrief: userCtx.LatestAdvisorBrief, + UpdatedContext: userCtx, + NextExpectation: nextExp, + ReplySource: source, + ReplyFallbackReason: fallbackReason, + }, nil +} + +func neutralUnmatchedReply(rawInput, projName string) string { + topic := strings.TrimSpace(rawInput) + runes := []rune(topic) + if len(runes) > 80 { + topic = string(runes[:80]) + } + if topic == "" { + topic = "这段内容" + } + return fmt.Sprintf("我收到您提到的「%s」(项目「%s」)。\n\n我可以按这个主题检索已有资料,或整理一版要点。请直接补充您最想先看到的材料,或说明您关心的具体问题。", topic, projName) +} + func (e *CoordinatorEngine) handleCasualChat(ctx context.Context, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) { projName := userCtx.Project.Name if projName == "" { @@ -100,9 +156,12 @@ func (e *CoordinatorEngine) handleCasualChat(ctx context.Context, userCtx *UserP reply := fmt.Sprintf("您好%s!我是您的项目助手,很高兴协助您推进医疗器械立项与研发协同。\n\n针对「%s」,我可以帮您:\n• 查询进展、成熟度与风险;\n• 沉淀客户需求与技术指标;\n• 起草沟通话术与方案要点;\n• 推进方案论证与报价核算。\n\n如果您愿意,我们可以先从您当前最关心的一块开始——您想先看进展、补事实,还是直接聊方案?", nameSuffix, projName) source := ReplySourceTemplate - if llmReply, llmSource := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { + fallbackReason := "" + if llmReply, llmSource, llmReason := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { reply = llmReply source = llmSource + } else { + fallbackReason = llmReason } // 路由层:复杂自由任务画像命中时,先给即时初答,再附升级建议(复用挂起期望机制,含每会话冷却) @@ -110,14 +169,15 @@ func (e *CoordinatorEngine) handleCasualChat(ctx context.Context, userCtx *UserP userCtx.applyPending(nextExp) return &CoordinationResult{ - Status: StatusAccepted, - Intent: intent.IntentCasualChat, - Branch: intent.BranchNewIntentPreempt, - NaturalReply: reply, - AdvisorBrief: userCtx.LatestAdvisorBrief, - UpdatedContext: userCtx, - NextExpectation: nextExp, - ReplySource: source, + Status: StatusAccepted, + Intent: intent.IntentCasualChat, + Branch: intent.BranchNewIntentPreempt, + NaturalReply: reply, + AdvisorBrief: userCtx.LatestAdvisorBrief, + UpdatedContext: userCtx, + NextExpectation: nextExp, + ReplySource: source, + ReplyFallbackReason: fallbackReason, }, nil } diff --git a/internal/coordinator/engine_query.go b/internal/coordinator/engine_query.go index 7d2d0e45..0ae49528 100644 --- a/internal/coordinator/engine_query.go +++ b/internal/coordinator/engine_query.go @@ -419,6 +419,93 @@ func (e *CoordinatorEngine) handleQueryRegulations(ctx context.Context, cand *in }), nil } +// handleQueryMaterials 检索项目资产与法规库中的说明书、视频、竞品资料。 +// 有命中则直接引用;没有命中则交给对话模型;模型也失败时给出中性追问。 +func (e *CoordinatorEngine) handleQueryMaterials(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) { + projectName := "当前项目" + if userCtx != nil && userCtx.Project.Name != "" { + projectName = userCtx.Project.Name + } + searchQuery := strings.TrimSpace(rawInput) + if projectName != "当前项目" && !strings.Contains(searchQuery, projectName) { + searchQuery = projectName + " " + searchQuery + } + + var hits []kb.HitItem + if e != nil && e.kbClient != nil && searchQuery != "" { + kbHits, err := e.kbClient.Search(ctx, searchQuery, kb.SearchOptions{ + Domains: []kb.KnowledgeDomain{kb.DomainProjectAsset, kb.DomainRegulatory}, + Limit: 5, + }) + if err == nil && len(kbHits) > 0 { + hits = kbHits + } + } + + if len(hits) > 0 { + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + Branch: cand.Branch, + NaturalReply: formatMaterialHits(projectName, rawInput, hits), + AdvisorBrief: userCtx.LatestAdvisorBrief, + UpdatedContext: userCtx, + ReplySource: ReplySourceKB, + }, nil + } + + reply := neutralUnmatchedReply(rawInput, projectName) + source := ReplySourceTemplate + fallbackReason := "" + if llmReply, llmSource, llmReason := e.generateDialogueReply(ctx, rawInput, userCtx); llmReply != "" { + reply = llmReply + source = llmSource + } else { + fallbackReason = llmReason + } + return &CoordinationResult{ + Status: StatusAccepted, + Intent: cand.Intent, + Branch: cand.Branch, + NaturalReply: reply, + AdvisorBrief: userCtx.LatestAdvisorBrief, + UpdatedContext: userCtx, + ReplySource: source, + ReplyFallbackReason: fallbackReason, + }, nil +} + +func formatMaterialHits(projectName, rawInput string, hits []kb.HitItem) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("关于「%s」里您要的「%s」,检索到这些材料:\n\n", projectName, strings.TrimSpace(rawInput))) + for i, h := range hits { + cite := h.CiteKey + if cite == "" && h.DocCode != "" { + cite = fmt.Sprintf("〔%s〕", h.DocCode) + } + title := h.Title + if title == "" { + title = h.DocCode + } + if title == "" { + title = "未命名材料" + } + sb.WriteString(fmt.Sprintf("%d. **%s** %s\n", i+1, title, cite)) + if h.HeadingPath != "" { + sb.WriteString(fmt.Sprintf(" - 位置: %s\n", h.HeadingPath)) + } + if h.Content != "" { + cText := strings.TrimSpace(h.Content) + if len([]rune(cText)) > 150 { + cText = string([]rune(cText)[:150]) + "..." + } + sb.WriteString(fmt.Sprintf(" - 要点: %s\n", cText)) + } + } + sb.WriteString("\n以上内容来自已索引资料。若还要某一份的操作步骤或和当前项目的差异,直接告诉我。") + return sb.String() +} + // handleQuerySimilarCases 处理标杆案例检索 func (e *CoordinatorEngine) handleQuerySimilarCases(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { brief := userCtx.LatestAdvisorBrief diff --git a/internal/coordinator/upload_followup.go b/internal/coordinator/upload_followup.go index 0bc84017..c1c94517 100644 --- a/internal/coordinator/upload_followup.go +++ b/internal/coordinator/upload_followup.go @@ -58,7 +58,7 @@ func (e *CoordinatorEngine) handleUploadIntake(ctx context.Context, userCtx *Use 【文档正文开始】 %s 【文档正文结束】`, shownTitle, truncateRunes(doc.Markdown, maxDocumentSummaryInputRunes)) - if generated, generatedSource := e.generateDialogueReply(ctx, crafted, userCtx); generated != "" { + if generated, generatedSource, _ := e.generateDialogueReply(ctx, crafted, userCtx); generated != "" { reply = generated source = generatedSource } else { diff --git a/internal/intent/recognizer_expectation.go b/internal/intent/recognizer_expectation.go index 79edde26..feb6f5c9 100644 --- a/internal/intent/recognizer_expectation.go +++ b/internal/intent/recognizer_expectation.go @@ -169,6 +169,12 @@ func matchOptionIndex(input string, options []ExpectationOption) int { return -1 } +// IsConsultativeOrHesitant 报告输入是否在征询意见或表达犹豫。 +// 只有这类输入才保留阶段对照话术;资料名、名词短语不得走该话术。 +func IsConsultativeOrHesitant(input string) bool { + return isConsultativeOrHesitant(input) +} + // isConsultativeOrHesitant 检验是否属于犹豫或征询参谋意见 (分支 3) func isConsultativeOrHesitant(input string) bool { patterns := []string{ diff --git a/internal/intent/recognizer_matchers_kb.go b/internal/intent/recognizer_matchers_kb.go index e6ff6e0d..06d6d820 100644 --- a/internal/intent/recognizer_matchers_kb.go +++ b/internal/intent/recognizer_matchers_kb.go @@ -84,6 +84,42 @@ func matchKnowledgeQuery(input string) *CandidateIntent { } } + if cand := matchMaterialRequest(input); cand != nil { + return cand + } + + return nil +} + +// matchMaterialRequest 识别说明书、操作视频、竞品资料等获取请求。 +// 写动词(生成/编写)留给任务触发,避免把「编写说明书」误判成检索。 +func matchMaterialRequest(input string) *CandidateIntent { + if hasTaskWriteVerb(input) || hasWeakTaskWriteVerb(input) { + return nil + } + for _, v := range []string{"撰写", "写一", "输出"} { + if strings.Contains(input, v) { + return nil + } + } + patterns := []string{ + "说明书", "操作视频", "产品视频", "彩页", "规格书", + "使用手册", "操作手册", "产品手册", "手册", + "上一代", "前代", "上代产品", + "竞品", "同类产品资料", + } + for _, p := range patterns { + if strings.Contains(input, p) { + return &CandidateIntent{ + Intent: IntentQuerySimilarCases, + Confidence: 0.82, + FilledSlots: map[string]string{ + SlotQueryTopic: "materials", + }, + Reasoning: "用户在索取说明书、操作视频、竞品或上一代产品资料,走知识检索", + } + } + } return nil } diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index 9670f065..3a581d8c 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -500,6 +500,49 @@ func TestIntentRecognition_QuerySimilarCases(t *testing.T) { if res.TopIntent.Intent != intent.IntentQuerySimilarCases { t.Errorf("expected intent QUERY_SIMILAR_CASES, got %s", res.TopIntent.Intent) } + if res.TopIntent.FilledSlots[intent.SlotQueryTopic] == "materials" { + t.Errorf("benchmark case query must not be classified as materials") + } +} + +func TestIntentRecognition_MaterialRequestsNotAmbiguous(t *testing.T) { + rec := intent.NewRecognizer() + inputs := []string{ + "上一代产品说明书", + "类似的全自动产品的说明书", + "竞品的操作视频", + } + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + res, err := rec.Recognize(context.Background(), input, nil) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if res.Branch == intent.BranchAmbiguousFallback { + t.Fatalf("material request fell into AMBIGUOUS_FALLBACK: %+v", res.TopIntent) + } + if res.TopIntent.Intent != intent.IntentQuerySimilarCases { + t.Fatalf("intent=%s want QUERY_SIMILAR_CASES", res.TopIntent.Intent) + } + if res.TopIntent.FilledSlots[intent.SlotQueryTopic] != "materials" { + t.Fatalf("query_topic=%q", res.TopIntent.FilledSlots[intent.SlotQueryTopic]) + } + if res.TopIntent.Confidence < 0.75 { + t.Fatalf("confidence=%v", res.TopIntent.Confidence) + } + }) + } +} + +func TestIntentRecognition_WriteManualNotMaterialLookup(t *testing.T) { + rec := intent.NewRecognizer() + res, err := rec.Recognize(context.Background(), "请撰写详尽合规手册:IEC 62304 各写 20 个检查项", nil) + if err != nil { + t.Fatalf("recognize failed: %v", err) + } + if res.TopIntent.FilledSlots[intent.SlotQueryTopic] == "materials" { + t.Fatalf("writing a handbook must not be a material lookup: %+v", res.TopIntent) + } } func TestIntentRecognition_QueryUserRights(t *testing.T) {