merge: candidate intent-material-2026-09-28 into dev

This commit is contained in:
weichen
2026-09-28 18:54:32 +00:00
19 changed files with 718 additions and 28 deletions
+3 -1
View File
@@ -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) | 本轮不修 |
@@ -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 二进制尚未替换。
- 修订与失效:若产品改为资料请求一律进小队任务,本条的检索分流不再适用,保留历史。
+1
View File
@@ -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 |
@@ -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
- 下一步:独立授权后再修「小队步骤失败必须落一条助手失败说明」。本记录不把该问题标成已修。
@@ -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,本报告不把它记成已修。
@@ -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": "关于「吞咽电刺激」里您要的「上一代产品说明书」,检索到这些材料:"
}
@@ -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
}
]
+5
View File
@@ -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
}
+49
View File
@@ -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)
}
}
+3
View File
@@ -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)
+24 -6
View File
@@ -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)
+85
View File
@@ -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
+7 -2
View File
@@ -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)
+78 -18
View File
@@ -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
}
+87
View File
@@ -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
+1 -1
View File
@@ -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 {
@@ -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{
+36
View File
@@ -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
}
+43
View File
@@ -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) {