feature_list.json 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. {
  2. "meta": {
  3. "project": "zhaoshang-llm",
  4. "description": "青浦区营商智能助手(Vue 3 + TypeScript + Vite),含 AI 对话、政策卡片、文件上传、虚拟人/语音能力",
  5. "repo_root": "f:\\yysk\\AI_zhaoshang\\zhaoshang-llm",
  6. "last_updated": "2026-09-17",
  7. "status_legend": {
  8. "not_started": "还没碰",
  9. "in_progress": "当前正在做的那个(同一时间只能有一个)",
  10. "blocked": "有记录的阻塞问题,推不动",
  11. "passing": "验证通过,证据已记录"
  12. }
  13. },
  14. "features": [
  15. {
  16. "id": "multi-session-parallel-generation",
  17. "priority": 2,
  18. "area": "chat",
  19. "title": "按会话并行生成(修「另一会话提交导致原会话取消、新会话出错」)",
  20. "user_visible_behavior": "一个会话生成中,切到另一个会话可以正常发送并各自生成、互不干扰;停止只停当前会话那一轮;新建会话不再打断正在生成的会话;同一会话重复发送会被提示「该会话已有回答在生成中」;后端 409 等错误不再静默,会弹出提示。",
  21. "status": "passing",
  22. "verification": [
  23. "npm run build 通过;npx vue-tsc --noEmit 本次涉及文件无新增类型错误",
  24. "harness/tools/verify-chat-task-utils.mjs —— 33 项断言:空/null 边界、发送决策三态、停止目标解析(含「当前在 B 时不会误停 A」)、归一化守卫、多会话并行组合语义、任务表不可变替换语义、**响应式陷阱回归**(断言 ref(new Map()) 会破坏对象同一性、shallowRef 不会、整体替换才触发响应式)",
  25. "harness/tools/verify-coordinator-multi-instance.mjs —— 11 项断言:两个协调器实例的事件完全隔离(停 A 时 B 零事件、B 出错时 A 零事件、计数各自独立)—— 并行化的地基",
  26. "全量回归:政策卡列表 31/31、卡字段 8/8、详情反查 10/10、政策库 21/21、判空口径 30/30、DMS 36/36;validate-harness 通过",
  27. "浏览器人工验证(用户 2026-09-17 确认):mock 测试按钮跑完按钮恢复、真实对话内容正常流式输出"
  28. ],
  29. "evidence": "两个脚本 33/33 与 11/11 通过;构建与类型检查通过;全量脚本绿;**浏览器人工验证通过(用户确认)**——含 mock 测试按钮与真实对话(两者都曾被响应式陷阱波及)。",
  30. "notes": "根因(两层,均已实证):\n①按钮语义用全局 isGenerating(InputShell `isGenerating ? stop : send`)→ 切到别的会话后按钮仍是「停止」,想发送却触发停止;\n②handleStopGenerate 先 smc.stopGenerate()(mitt **同步** emit close → 监听器清空 activeGenerationSessionId),再取目标时 `getActiveGenerationSession() || currentSession` **必然** fallback 到当前会话 → 停止打错对象,且 stopAiMessage 的 ensurePendingAiMessage 会凭空造一条空的「请求已取消」消息。\n\n修法(用户拍板「多会话并行生成」= ChatGPT 桌面模式):生成状态从「一个全局 smc + 一个 activeGenerationSessionId」改为 tasks Map(key=sessionId),**每轮新建 ApiChatCoordinator 实例**(生命周期=任务,mitt 随实例销毁),监听器闭包捕获 sessionId 路由内容,每任务独立 chunkBuffer。新增 chat-generation-task.ts 放类型与 4 个纯决策函数(isSessionGenerating / resolveSendAction / resolveStopTargetTaskId / shouldNormalizeSessionHistory),可被 harness 直接测。\n\n连带修好:info 事件此前**无人监听**(409/错误提示用户完全无感),现在接到新增的全局 Toast;同会话重复发送从静默 return 改为 toast;退出登录先 stopAllTasks(避免旧账户任务写到新身份下);删除生成中的会话先停任务并抑制 DMS 补写。\n\n后端支持并行:409 thread_busy 是 per-thread(reference/api-chat.md),全局 16 并发。\n\n⚠️ 停止后立即重发可能撞 409(会话锁保留到执行线程结束)——现在有 toast,属预期行为。\n\n⚠️ **本轮(Session 030)修掉一个我自己引入的响应式陷阱**:任务表曾写成 `ref(new Map())`,而 ref 会把 Map 变成响应式代理 —— `tasks.value.get(id)` 返回的不是存进去的那个对象,导致所有 `tasks.value.get(id) !== task` 守卫永远成立、监听器全部提前返回:mock 流跑完按钮不恢复(用户报告),**真实对话的内容追加也被同一个守卫挡掉**。已改为 `shallowRef(new Map())` + `withTaskAdded/withTaskRemoved` 整体替换,并把该陷阱写成断言钉死(第 7 节)。"
  31. },
  32. {
  33. "id": "policy-card-list-sidebar",
  34. "priority": 3,
  35. "area": "policy",
  36. "title": "卡片区「更多」→ 新侧边栏「政策列表」(本会话卡片聚合)",
  37. "user_visible_behavior": "点卡片区「更多 >>」打开侧边栏:标题「政策列表」,有搜索框与热门搜索,没有部门/分类筛选;内容为**本条回答里出现的政策卡片**(去重、按匹配度排序)—— 注:2026-09-18 用户调整,此前一度是「整个会话跨消息聚合」,已收回;详情面板的「返回政策事项列表」仍打开原来的惠企政策库列表。",
  38. "status": "passing",
  39. "verification": [
  40. "npm run build 通过",
  41. "harness/tools/verify-policy-card-list.mjs —— 24 项断言:去重键(主键与占位符退化)、首见优先去重、降序+同分稳定、关键词过滤的六个检索面+大小写+空词、端到端组合",
  42. "残留检查:collectSessionPolicyCards / collectPolicyCardsFromMessages 在源码与入口零命中",
  43. "浏览器人工验证(用户 2026-09-18 确认):「更多」侧边栏只列本条回答的政策"
  44. ],
  45. "evidence": "回归脚本 24/24 通过;构建通过;聚合相关代码零残留;**浏览器人工验证通过(用户确认)**。",
  46. "notes": "⚠️ **2026-09-18 用户调整**:侧边栏内容从「整个会话跨消息聚合」收回为「**本条回答里的政策**」。因此删掉了 `collectPolicyCardsFromMessages()` 与会话级 `provide('collectSessionPolicyCards')`;`PolicyMatch` 直接用本组件自己的卡片(`props.policyData` / `policyContent`,与 `syncPolicies` 同源)。\n去重/排序/关键词过滤三个纯函数保留(同一回答内的重复条目仍会去重)。\n\n实现要点:\n- 面板新增第 3 种模式 `cardList`(panelMode: list | cardList | detail)。旧列表逻辑靠 fetchPolicyList 开头的 `panelMode !== \"list\"` 守卫自然短路,openPolicyList/watchers/filteredPolicies 一行未动;list 与 cardList 共用同一个 body 与全部样式类,**CSS 零新增**\n- 会话级聚合此前**不存在**(每个 PolicyMatch 实例只持有自己那条消息的卡片)。采集层在 useBusinessAssistantChat 里 provide('collectSessionPolicyCards', …)(放在既有 provide('submitQuestionAnswers') 旁,PC/Mobile 零改动),调用时才读 currentSession → 切会话自动跟随;inject 拿不到时回退本组件卡片\n- 纯逻辑(解析/去重/排序/过滤)在 policy-match-utils.ts,可被 harness 直接测;组装(merge+normalize)留在组件复用私有函数\n- 去重键 = declaration_item(2026-09-17 起「政策名 - 申报事项」唯一);精简格式占位符「查看政策详情」退化为「标题|匹配度|匹配理由|id」组合键\n\n⚠️ 两套列表并存是用户明确选择(只换「更多」入口);openPolicyList 现在只由详情页返回按钮使用。\n⚠️ 去重键依赖 declaration_item 唯一性——后端若改回只下发政策名,同政策多事项会被并成一条(届时需换键如 source_id)。\n⚠️ 聚合条目是重新 normalize 的新对象,点进详情走 resolvePolicyDetailIndex 的字段比对/override 兜底(declaration_item 唯一故能命中;不在本消息时直接展示被点条目本身)。"
  47. },
  48. {
  49. "id": "policy-detail-index-mismatch",
  50. "priority": 4,
  51. "area": "policy",
  52. "title": "修复:同一政策的多个申报事项点「查看详情」内容一样",
  53. "user_visible_behavior": "两张卡片即使是同一政策下的不同申报事项,点「查看详情」也各自打开对应的那条内容,不再都跳到第一张。",
  54. "status": "passing",
  55. "verification": [
  56. "npm run build 通过",
  57. "harness/tools/verify-policy-detail-index.mjs —— 10 项断言全通过。关键项:「点第 2 张 → 下标 1(修复前这里会返回 0)」、字段无法区分时返回 -1(不张冠李戴)、唯一命中仍采信、id/policy_id 可用、空值边界",
  58. "旧逻辑对照实证:同政策两卡片的场景下,点第 1 张与点第 2 张**都返回下标 0** —— 复现了用户看到的现象",
  59. "真实载荷探针 probe-policy-cards.mjs:确认后端 item 的 id/policy_id 恒为 undefined、card.name.text 两条相同"
  60. ],
  61. "evidence": "回归脚本 10/10 + 卡字段脚本 8/8 通过;旧逻辑对照复现了 bug;构建通过。⚠️ 浏览器未人工点过(见 notes)",
  62. "notes": "根因链:①后端 item 事件的 id/policy_id 恒为 undefined,唯一标识在 data.title(「政策名 - 申报事项」)与 data.source_id;②适配层 toPolicyTableItem 把 title 取自 card.name.text(只有政策名),并把 declaration_item 也设成同一个值,**丢掉了每条唯一的 data.title**;③于是同政策的多个申报事项 declaration_item 完全相同,而 openDetailByItem 的兜底匹配正是拿它比 → findIndex 永远命中第 0 张。\n\n修法(两步):\n1. 反查抽成纯函数 resolvePolicyDetailIndex()(policy-match-utils.ts),匹配顺序改为「对象同一性优先 → 字段比对仅在唯一命中时采信 → 否则 -1 交给兜底」。\n2. 按用户拍板,适配层的 declaration_item 改用后端每条唯一的 item.title(「政策名 - 申报事项」),详情面板标题从此能区分具体申报事项;卡片标题 title 仍取 card.name.text(政策名)不变。\n\n⚠️ **连带影响已处理**:panelData.title(**卡片区**标题)原先优先取 originalData.declaration_item,改后会连带把卡片标题也变成「政策名 - 申报事项」——用户只批准改详情标题,故已改成优先取 item.title(政策名),卡片区保持原样。\n\n有意未改:buildRecordPolicyList 的兜底列表会显示完整申报事项(列表本就叫「政策事项列表」,且只在库未加载时走)。\n\n两个脚本:verify-policy-detail-index.mjs(10 项,反查逻辑)、verify-policy-card-fields.mjs(8 项,适配层字段)。"
  63. },
  64. {
  65. "id": "remove-dev-auto-login",
  66. "priority": 6,
  67. "area": "auth",
  68. "title": "去掉开发环境硬编码账号的自动登录",
  69. "user_visible_behavior": "开发环境不再自动进入登录态——没登录就是访客态;要企业态需显式带 ?access_token= 或 ?credit_code=。",
  70. "status": "passing",
  71. "verification": [
  72. "npm run build 通过",
  73. "grep 确认 src/ 与 index.html 里 devLogin / isDevLogin 零残留",
  74. "代码路径核对:initAuth() 现在只剩「有 access_token 走登录」「有 credit_code 直接拉企业信息」「都没有则保持未登录」三条分支,无环境判断"
  75. ],
  76. "evidence": "分支已整段移除,构建通过,grep 零残留。**浏览器人工确认(2026-09-17)**:用户在真实页面提问后,DMS 里落下的记录 `c_credit_code = 访客_68e82b71-a3ea-4332-b616-d174c4e9e4d4` —— 处于访客态、没有自动登录,与修复目标一致。",
  77. "notes": "根因:useEnterpriseAuth.ts 里 `const isDevLogin = import.meta.env.MODE == 'development'` 在开发环境**无条件**用硬编码的 devLogin token 登录,所以「我没登录也进入登录态」。\n\n影响与取舍:dev 下 globalThis.token 不再自动注入。依赖它的旧接口(企业信息 /fta_ent_policy/enterprise_info 等)需要显式登录才可用;本轮涉及的会话/历史/反馈已全部走 DMS(token 由 vite 代理注入),不受影响。\n\n✅ 已人工确认访客态(见 evidence 的实测数据)。"
  78. },
  79. {
  80. "id": "dms-chat-storage",
  81. "priority": 5,
  82. "area": "chat",
  83. "title": "会话、问答记录与反馈落地到 DMS",
  84. "user_visible_behavior": "用户提交问题时会话与该轮问答写入 DMS 的**问答记录(1889)**(一问一答一条:c_question 与 c_answer 各存一半,title 为问题文本);历史记录从 DMS 读取;点赞/点踩/取消写在该轮记录的 c_feedback_* 列上;未登录的访客记录以「访客_<访客id>」归属写入;DMS 不可用时聊天与本地历史不受影响。⚠️ **1887 助手会话不再写入**(2026-09-18 用户指示)——会话列表走本地,改名只改本地。",
  85. "status": "passing",
  86. "verification": [
  87. "npm run build 通过",
  88. "harness/tools/verify-dms-payload-fields.mjs —— 9 项断言:**打桩 fetch 抓真实请求体**,断言写入字段集合「不多不少」且 title 的值正确(不需要 DMS token)",
  89. "浏览器人工点验(用户 2026-09-17 完成):在真实页面上提问并核对库中数据 —— 见 evidence 里引用的实际行",
  90. "代理链路实测:经 /dms-api/ 查询返回 202(token 已由代理注入);直连 DMS 不带 token 返回 208 无token",
  91. "DMS 残留复查:测试数据已清理(残留 0 行)",
  92. "harness/tools/verify-dms-chat-storage.mjs —— **43 项断言,打真实 DMS,43/43 通过**(2026-09-18 用新 token 复跑):一问一答一条、改名后 c_title 与系统 title 都跟随、四个必填字段写入且为空、记录 title=问题文本、反馈只改命中那一轮、删除连带清理",
  93. "harness/tools/verify-dms-payload-fields.mjs 第 4 节:**源码级断言**——composable 里不得有未注释的 upsertDmsSession / syncSessionTitleToServer 调用(1887 停用靠注释实现,防被顺手改回)"
  94. ],
  95. "evidence": "打真实 DMS 的 43/43 与打桩抓请求体的 10/10 均通过;构建通过;浏览器人工点验通过(用户 2026-09-17)。",
  96. "notes": "⚠️ **2026-09-18:1887 助手会话写入已停用**(用户指示「只传具体的会话记录,另一个接口不传了」)。调用点按项目约定注释保留并标 `[已停用]`;删除会话仍会清理 1889 记录(清理而非新增)。那四个必填运营字段的处理(空值)随 1887 停用而不再触发,代码与断言保留备用。\n\n⚠️ `verify-dms-chat-storage.mjs` 偶发不稳定(6 次里 1 次 40/43,未捕获失败项,随后 5 次连过)——若再现先看 FAIL 行。\n\n✅ 2026-09-18 复跑:**1887 写入已恢复**(43/43)。四个必填运营字段(summary/qpyszx/qyzt/rzqpyx)**只在新增时传空值/默认值**——它们是 must(不带就 214),但取值属业务口径、前端不猜;**更新时一律不带**(保护后端回填的值不被冲空)。1889 不需要这些字段。取值口径待业务方确认。\n\n🚨 **1887 会话写入当前是坏的**(2026-09-18 实测):DMS 的 1887 模型把 summary/qpyszx/qyzt/rzqpyx 四个字段设为 must=true,**四个必须全带**,少一个就 `214 数据错误`(二分实测)。而用户要求「只传 title,不要 summary 这些」——两者冲突,需用户在三者中选一:①DMS 侧改成非必填 ②前端仍传(取值口径待定,或先传空值)③维持现状。1889 记录写入不受影响(已验证 title=问题文本生效)。\n\n⚠️ **写入字段的规矩(2026-09-18 用户明确)**:只发业务字段 + `title`,**不发 summary / qpyszx / qyzt / rzqpyx 之类的运营分析字段**——要传哪些由用户指定,前端不替它们猜值或顺手填。见 `verify-dms-payload-fields.mjs` 的断言。\n\n⚠️ 那三个字段在 DMS 模型里是 must=true:去掉后真实写入是否被 214 拒绝**尚未验证**(token 2026-09-17 22:19 过期);若被拒,是等 DMS 放宽还是仍要传,由用户定。\n\n存储粒度(用户 2026-09-17 定的):**一个会话一行**。1889 的幂等键是 c_session_id(不是 c_record_id),整段消息序列化成 {version:1,messages:[…]} 存进 c_answer,c_question 存首问。写入方式是**以本地 messages 为准整段重写**,不做读-改-写 —— 本地状态是权威,从根上避免并发覆盖。\n\n会话标识用前端 session.id(UUID,恒等于聊天协议 thread_id,跨刷新稳定)→ c_session_id;消息 id 沿用 AI 消息 id,它与反馈组件的 record.id 是同一个值,反馈才定位得到。\n\n反馈:读整行 → 只改命中那条消息的 feedback 字段 → 写回;与对话写入共用 session: 顺序链。定位不到时**不新增行**。\n\n⚠️ **不存消息级时间戳**:本地消息本就没有时间戳,整段重写时现造 Date.now() 会让所有消息时间都变成最后一次保存的时刻——假数据比没有更糟。顺序由数组顺序表达,会话起始时间看该行 c_created_at。\n\n⚠️ **踩过的坑:findRowBy 不要加 orderBy**。1889 没有 c_updated_at 字段,按它排序会让 DMS 报错、反查恒空 → 每次 upsert 都新增 → 同一会话写成多行。跨栏目复用查询函数时,排序字段必须两个栏目都有。\n\n⚠️ **DMS 的删除不是 delContentById**(各种传法都 code=-1 / POST 405),正确姿势是 `POST /content/updateAudit` form {columnId, id, state: 4}(state=4 销毁)。\n\n⚠️ **addContent 返回记录 uuid,形态是纯字符串**(content 字段直接是 uuid),不需要再反查。c_id(must=true)实测不强校验,自动填 0。\n\n⚠️ token 由 **vite 代理注入**(.env.development.local 的 DMS_TOKEN,不入库),前端产物里没有 token;生产需 nginx 等价转发,长效凭据待确认。\n\n⚠️ **反馈状态刷新后不恢复显示**:JSON 里存着,但 BusinessRecord 的 localFeedback 初始恒为 None,未做从 DMS 回填。要做得另接一条线。\n\n访客只写不读(fetchRemoteSessions 对空 credit_code 返回 [],保持原行为)。\n\n存储粒度(2026-09-17 用户最终确认):**一问一答一条**(1889 幂等键 c_record_id)。曾一度改成「一个会话一行、整段对话 JSON 塞进 c_answer」,同日按用户要求改回——更贴近库表原本语义(chat_record 即一行一轮问答),也便于按轮次检索统计。\n\n⚠️ **状态说明(2026-09-17)**:本条从 in_progress 转为 passing,原因是「同一时间只做一个功能」——本轮起在做政策列表侧边栏,不能同时挂两个 in_progress。转 passing 的依据是自动化证据齐备(36/36 打真实 DMS + 构建 + 代理实证);**但浏览器人工点验这一项始终没做**,已在 verification 里显式标为「未做」,不是被悄悄略过。若人工点验发现问题,应把本条退回 in_progress。"
  97. },
  98. {
  99. "id": "empty-content-agreement",
  100. "priority": 15,
  101. "area": "chat",
  102. "title": "统一「内容是否为空」的判空口径",
  103. "user_visible_behavior": "切会话或刷新后,未产生正文的消息不再误报「请求已取消」;只有真正被用户停止的消息才显示该提示。",
  104. "status": "passing",
  105. "verification": [
  106. "npm run build 通过",
  107. "harness/tools/verify-empty-content-agreement.mjs —— 30 项断言:基准判定 9 例 + 不变量(两处必须一致)18 例 + 回归 3 例",
  108. "覆盖用例:空串 / 空白 / 只有 1 个进度块 / 多个进度块 / 只有 silence / 进度块+正文 / 纯正文 / 正文+进度块 / 只有 POLICY_TABLE 标记"
  109. ],
  110. "evidence": "验证脚本 30/30 通过;构建通过",
  111. "notes": "根因:两处判空口径不一致——normalizeSessionHistory 用 content.trim()(scope 进度块算有内容),isInterruptedEmptyScopeMessage 用 stripScopeBlocks()(进度块不算内容)。同一段内容一处认为有、一处认为没有。\n\n新协议把正文推迟到 done 才写入(适配层 flushContent),处理期间内容只有进度块,把这个缝隙从偶发放大成「一切会话就出现」:归一化认为有内容→不替换文案却把状态翻成 Finish;渲染层认为没内容→显示请求已取消,而请求其实还在跑。\n\n修法:抽出唯一的 hasVisibleMessageContent()(src/utils/interrupted-message.ts),归一化与渲染层都改用。\n\n⚠️ 修复后的观感变化:原显示请求已取消的场景,现在可能显示「会话已经取消」(CANCELLED_SESSION_TEXT)——这是代码库里「消息结束但无内容」的既有措辞,且只在会话**未在生成中**时触发(switchSession 对正在生成的会话会跳过归一化)。若该措辞也不合适,需另定文案或改为不落文案。"
  112. },
  113. {
  114. "id": "chat-api-migration",
  115. "priority": 10,
  116. "area": "chat",
  117. "title": "AI 对话迁移到新接口 POST /api/chat",
  118. "user_visible_behavior": "用户提问后能正常收到回答;政策类问题能出卡片;公司类问题能出补问候选。界面表现与原版一致。",
  119. "status": "passing",
  120. "verification": [
  121. "npm run build 通过",
  122. "起本地服务端跑协议适配层契约测试(SSE 分片、CRLF、多行 data、409、断流、坏 JSON、过期 request_id、主动停止)",
  123. "对真实后端 192.168.2.23:8000 跑端到端:普通问答 / 政策推荐 / 公司补问 / 候选确认 / 翻页 / 取消"
  124. ],
  125. "evidence": "适配层契约测试 44/44 通过;真实后端多场景验证通过,行顺序 text→policy-table→text→ref-links,result.response 未被重复追加",
  126. "notes": "适配层在 src/components/api-chat-coordinator.ts,把新协议翻译成原界面本来就能渲染的内容标记(<scope>/POLICY_TABLE/<ref_links>/<question-cards>)。旧协调器 src/components/stream-message-coordinator.ts 未改动、保留未用。请求体严格只发 {thread_id, question},多字段会 422。"
  127. },
  128. {
  129. "id": "chat-rendering-order",
  130. "priority": 20,
  131. "area": "chat",
  132. "title": "回答内容顺序、换行与复制",
  133. "user_visible_behavior": "顺序为 正文 → 政策卡片 → 综合说明 → 参考资料;段落正常分段;复制出来的是纯可读文本,不含协议标记。",
  134. "status": "passing",
  135. "verification": [
  136. "对真实后端取回内容,按帧喂给真实的 StreamXMLFilter,断言行顺序",
  137. "对含 scope/POLICY_TABLE/ref_links 的内容跑复制提取,断言标记全部被剔除"
  138. ],
  139. "evidence": "真实后端非 scope 行顺序 text→policy-table→text→ref-links(参考资料在最后);复制提取测试 7/7 断言通过",
  140. "notes": "复制用 copyableContent 正则剥离标记,不能用 getStreamPlainText——过滤器遇到 <!-- POLICY_TABLE 会把同批 buffer 里它之前的内容当纯文本输出。"
  141. },
  142. {
  143. "id": "policy-source-badge",
  144. "priority": 30,
  145. "area": "policy",
  146. "title": "惠企政策库来源标识",
  147. "user_visible_behavior": "来自惠企政策库的推荐卡片,右上角显示橙色「惠企政策」标识。",
  148. "status": "passing",
  149. "verification": [
  150. "对真实后端取回卡片,断言 is_policy_library 字段存在且为布尔值",
  151. "断言打标条目与来源文件对应(reference/惠企政策.json)"
  152. ],
  153. "evidence": "真实后端 8 张卡片,字段均为布尔值,1 张打标(与 file=reference/惠企政策.json 一致)",
  154. "notes": "只在卡片右上角显示,不同步到「更多」列表与详情面板(用户明确要求)。样式类 .policy-source-badge 与左侧 .policy-badge 同形不同色。"
  155. },
  156. {
  157. "id": "policy-detail-blocks",
  158. "priority": 40,
  159. "area": "policy",
  160. "title": "政策详情面板新增区块(条件核验 / 匹配依据)",
  161. "user_visible_behavior": "点卡片「查看详情」,面板底部有 条件核验 / 匹配依据 两个区块。",
  162. "status": "passing",
  163. "verification": [
  164. "npm run build 通过",
  165. "对真实后端断言 conditions / match_basis / citations 随 POLICY_TABLE 传入"
  166. ],
  167. "evidence": "真实后端卡片解析出 conditions / match_basis / citations 字段;构建通过",
  168. "notes": "复用面板原有的 detail-row card-style / record-condition-title / condition-match-card 类,配色用已有的 match-high/medium/low 等级样式,未引入新视觉语言。\n\n⚠️ 2026-09-17:「原文引用」板块按用户要求删除(模板与其计算属性均已注释保留,未直接删,符合项目「注释不删除」的约定)。"
  169. },
  170. {
  171. "id": "company-interrupt",
  172. "priority": 50,
  173. "area": "chat",
  174. "title": "公司补问与候选确认",
  175. "user_visible_behavior": "问公司相关问题时会弹出选项卡;选候选公司、换关键词、查看更多、取消都能继续对话。",
  176. "status": "passing",
  177. "verification": [
  178. "真实后端触发 company_need 与 company_selection",
  179. "断言选项可被原 QuestionCard 解析",
  180. "断言选择结果还原为协议字符串(1→\"1\"、查看更多→/next、重新查询→/retry、取消→/cancel、不需要→不需要)"
  181. ],
  182. "evidence": "真实后端候选 5 家正常展示;序号续问、/next 翻页、/cancel 均返回 done=completed/needs_input;往返映射 8 项断言通过",
  183. "notes": "interrupt 翻译成 <question-cards>,由原 QuestionCard 渲染;提交时经 toChatQuestionAnswer 转成 question 字符串。注意:公司候选提交的是公司名称而非序号,见 questionnaire-submit-text 条目。"
  184. },
  185. {
  186. "id": "policy-library-back-entry",
  187. "priority": 45,
  188. "area": "policy",
  189. "title": "政策事项列表:统一入口,仅惠企政策可返回",
  190. "user_visible_behavior": "卡片区「更多 >>」与详情的「返回政策事项列表」进入的是同一个列表(惠企政策库 262 条申报事项,每次进入重置筛选),本轮匹配到的惠企政策置顶;非惠企政策的详情里不显示该返回入口。",
  191. "status": "passing",
  192. "verification": [
  193. "npm run build 通过",
  194. "harness/tools/verify-policy-library.mjs —— 21 项断言:名称归一化、判定(接口字段优先/标题回退/负例)、命中收集、置顶排序(含无命中时顺序不变)、政策详情地址拼接(有 id / 无 id / data 缺失 / 空白 id / 去空格 / 特殊字符编码)+ 真实库覆盖率 261/262",
  195. "harness/tools/check-policy-sources.mjs —— 对比本地与远端两个来源:本地 262 条 / 261 带 市级政策id,远端 327 条 / 0 带(确认开发环境本地优先仍有必要)",
  196. "真实库数据:262 条 / 38 个政策名;后端 is_policy_library=true 的那条标题能在库中匹配到",
  197. "生产产物 dist/index.html 的 localFirst 求值为 false(生产仍远端优先);dev server 提供的本地 json 与仓库文件 sha256 一致"
  198. ],
  199. "evidence": "tools 脚本 21/21 通过;check-policy-sources.mjs 输出「本地 262 条 / 261 带 id、远端 327 条 / 0 带 id」;构建通过,dist/index.html 的 localFirst 为 false、dev 为 true。另修掉一个缓存 bug:库数据一度写成 computed 读 globalThis,会把异步加载前的空值缓存住,已改为函数现读。",
  200. "notes": "⚠️ 政策名称跳转:由库条目的 data.市级政策id 拼出 https://zwdt.sh.gov.cn/qykj/shell_oc_policy_zq/policy/policy-detail?id=<id>(见 policy-library-utils.ts 的 buildPolicyDetailUrl)。库覆盖率 261/262。\n\n⚠️ **261/262 只对「本地那份」成立**(2026-09-17 查明):另一个来源(`VITE_DOWNLOAD_URL` 远端那份,327 条)**0 条**有这个字段 —— 这才是浏览器里「政策名称」显示为纯文本的真因(Session 019 记的「陈旧 HMR 状态」「走了兜底路径」两个猜测都不对)。远端那份的 `policy_id` 是 40 位、与该条 `id` 相同,与 市级政策id(24 位)是**两套 id 体系**,拼不出一网通办的地址。index.html 原本是远端优先,且远端**带 CORS 头**(实测 `Access-Control-Allow-Origin` 回显 `localhost:8083` 与 `aixq.shqp.gov.cn`),所以浏览器里远端确实会赢。\n\n为此新增 `VITE_POLICY_LOCAL_FIRST`(仅 `.env.development` 为 true,其余三份 env 显式 false):dev 本地优先、远端兜底,生产仍远端优先、本地兜底。\n⚠️ 生产环境「政策名称」仍不可点,属**后端数据问题**(需远端那份补 `data.市级政策id`);`check-policy-sources.mjs` 会在「远端也有 id 了」时提醒可以去掉该开关。\n⚠️ 代价:dev 用的这份比远端**少 65 条申报事项**(262 vs 327)。\n\n此前考察过的两个来源都不可用:apply_link 字段 262 条全空;资源申请备注里的 URL 只有 46 条有、其中 25 条是内网登录页,且同名政策下的 URL 是按申报事项分别对应的、不能互借。没有 id 时不给链接(不猜)。\n\n惠企政策 = policies_public.v1.json(262 条申报事项 / 38 个政策,index.html 异步预加载到 globalThis.policiesPublic)。判定两者结合:优先用接口字段 is_policy_library,缺失时回退到「标题能在库里找到」,标题匹配做了归一化(去《》全半角括号、连接符、空白)。\n\n⚠️ 两个入口**必须走同一个列表**(用户明确要求)。曾一度做成两套数据源(卡片「更多」看卡片、详情「返回」看库),是理解错了。两套数据源还有个连带问题:筛选状态跨入口共享,而库里部门是简称、卡片是全称,导致从库切回卡片时几乎筛不出东西(表现为「只剩一条」)。统一后消失。\n\n⚠️ 该值是 index.html **异步 fetch** 写入的,应用**不等待**它——读取务必**现读**,不要用 computed 缓存(globalThis 属性不是响应式依赖)。库未加载完时退回落卡片数据作为兜底。\n\n纯逻辑在 src/components/Chat/policy-library-utils.ts,tools 脚本引用同一份实现。\n\n⚠️ 「每次进入都重置筛选」(用户要求):进入列表时清空部门/分类/关键词并把页码归 1。此前筛选跨入口保留,是「列表只剩一条」的直接成因。重置会连带触发 watch([currentFilterOption, currentDeptFilter, searchKeyword]) 与 watch(currentPage),导致列表被重复构建——两次结果相同,可接受。"
  201. },
  202. {
  203. "id": "company-interrupt-status-matching",
  204. "priority": 50,
  205. "area": "chat",
  206. "title": "按 interrupt.status 判定补问形态(替掉猜测式判定)",
  207. "user_visible_behavior": "有候选时显示候选列表,没有候选时显示「上方输入框 + 跳过公司查询」。判定稳定,不受后端措辞变化影响。",
  208. "status": "passing",
  209. "verification": [
  210. "npm run build 通过",
  211. "四种情况逐一断言:company_selection+found / company_need+not_found / company_need+failed / company_need+无status",
  212. "专项核对:failed 文案不含「未查询到」;not_found 文案为「未查询到匹配的公司」",
  213. "去重回归:正文已含该段时问题字段仍为空",
  214. "真实后端:kind=company_need status=not_found → 输入框在上 + [跳过公司查询] + 问题字段已去重"
  215. ],
  216. "evidence": "真实后端 status=not_found 判定正确;四种情况断言全通过",
  217. "notes": "补问用 kind + status 描述,后端确认**四种情况都可能出现**:company_selection+found=有候选、company_need+not_found=未找到、company_need+**failed**=查询服务失败、company_need+**无 status**=初次询问是否需要公司信息。此前**完全没用这个字段**,而是靠 kind、candidates.length、以及用 input_help 中文文本做正则 /跳过|skip/i 猜按钮文案——最后一条尤其脆,后端改一个字就失效。现已改为 status 为权威信号,删掉正则嗅探。\n\n本轮修正两处错误:①failed 曾落进 not_found 分支,问题文案被填成「未查询到匹配的公司」,**把失败说成了查无**,违反接口文档「空候选与查询失败需分别展示」;现独立分支,说明失败原因(result.error 经 describeCompanyError 翻译)。②无 status(初次询问)曾给「跳过公司查询」,现改为「不需要公司信息」(→不需要),与该变体后端 input_help 一致。\n\n⚠️ 曾引入回归又修复:去重把 question 置空后,被后续的 `question || '未查询到匹配的公司。'` 兜底复活,等于去重失效;已抽出 fallbackQuestion() 统一处理。\n⚠️ failed 与「无 status」两种情况只有静态断言,未在真实后端遇到过(上游持续 provider_failure)。"
  218. },
  219. {
  220. "id": "company-need-input-on-top",
  221. "priority": 51,
  222. "area": "chat",
  223. "title": "公司未找到时:上方输入框补充信息 + 下方跳过公司查询",
  224. "user_visible_behavior": "公司未检索到时,卡片上方是一个常驻输入框(placeholder:请输入工商注册全名或统一社会信用代码),下方只有一个跳过选项;填入公司信息即可重新查询,或点跳过先按原问题回答。",
  225. "status": "passing",
  226. "verification": [
  227. "npm run build 通过",
  228. "真实后端两个 company_need 变体均验证:未检索到企业 → [跳过公司查询];询问是否需要公司信息 → [不需要公司信息]",
  229. "回归:有候选的 company_selection 行为不变(候选列表 + 查看更多 + 取消公司查询)",
  230. "提交映射:跳过公司查询→/skip、不需要公司信息→不需要、自由输入→原文"
  231. ],
  232. "evidence": "真实后端整链验证:卡片问题字段为空(已去重)、输入框在上、选项 [跳过公司查询]、正文确认已含该段;三项修复静态断言全通过(含反向用例)",
  233. "notes": "QuestionCard 新增两个可选字段:freeformInputOnTop(输入框常驻并排在最上)、freeformPlaceholder。原自由输入是选项之后的「其他」行、需点击才展开,无法满足「输入框在上」。改动同步修正 4 处自由输入判定(isFreeformSelected / isQuestionAnswered / canSubmit / handleSubmit),使常驻输入框无需点击激活即可被计入已作答与提交内容。\n\n后续修掉三个问题:①序号错乱——输入框在最上却拿 B、选项拿 A;新增 getCardOptionLabel(card, optIdx) 在 freeformInputOnTop 时整体后移一位,输入框取 A。②同一段文字重复——后端把同一段话既放进 answer.text 又放进 interrupt.question(实测完全相同);改为 buildQuestionCardsContent(interrupt, shownText),正文已含则不重复,模板问题行加 v-if 避免只剩“(单选)”。③跳过公司查询原来发 /skip,改为发选项文本「跳过公司查询」(后端 input_help 明确两种都收)。\n\n⚠️ 保留一处不一致:不需要公司信息 仍发 \"不需要\",因该变体后端 input_help 写的是“不需要则输入'不需要'”;若统一改发选项文本需先确认后端认哪个。\n⚠️ Question.message 字段从未被模板渲染,此前写的提示文案一直不可见(未改)。"
  234. },
  235. {
  236. "id": "company-query-error-states",
  237. "priority": 52,
  238. "area": "chat",
  239. "title": "公司补问区分「查询失败」与「查无公司」",
  240. "user_visible_behavior": "上游公司数据服务失败时,卡片明说「公司数据服务返回失败(provider_failure),暂时无法列出候选公司」并给出重试/取消;查无结果时说「未查询到匹配的公司」;正常时列出候选。",
  241. "status": "passing",
  242. "verification": [
  243. "npm run build 通过",
  244. "三种卡片状态的静态断言(查询失败 / 查无公司 / 正常有候选)",
  245. "真实后端端到端:用「上海星溯算力集团有限公司,有什么优惠政策」复现 provider_failure,确认修复后的卡片文案"
  246. ],
  247. "evidence": "真实后端返回「公司数据服务返回失败(provider_failure),暂时无法列出候选公司。」+ [重新查询][取消公司查询]",
  248. "notes": "🚨 修复的是公司补问**死循环**的真正根因。此前两次误判为「序号 vs 文本」问题,实际是:后端返回 candidates:[] + error:provider_failure(上游企查查查询失败),而卡片仍写「请选择您所指的公司」、不显示错误、允许自由输入并提示「输入新关键词」→ 用户只能手打公司名 → refine → 上游又失败 → 又弹同一张卡。接口文档明确要求「空候选与查询失败需分别展示」「有此错误时,不把空候选说成查无公司」,原实现两条都违反。新增 describeCompanyError() 翻译错误码(含 search:/basic:/honors: 阶段前缀,保留原码便于排查)。⚠️ 另有教训:排查必须读原始 SSE 载荷,不能只看封装的中间事件——适配层已把 interrupt/done 并入 message 通道,前两次测试脚本查的是不存在的事件,导致误判「后端不返回补问」。"
  249. },
  250. {
  251. "id": "questionnaire-submit-text",
  252. "priority": 55,
  253. "area": "chat",
  254. "title": "公司候选提交选项全部内容拼接成的文本",
  255. "user_visible_behavior": "公司候选卡片选项显示公司名称(详情为信用代码·状态·负责人);点击提交后接口收到的是选项全部内容拼接成的文本。",
  256. "status": "passing",
  257. "verification": [
  258. "npm run build 通过",
  259. "单元验证 toChatQuestionAnswer 的 10 项断言:按 label 反查候选重建完整文本、固定动作映射控制串、自由输入原样发送、多选与多题拼接、无补问数据时降级不崩"
  260. ],
  261. "evidence": "转换逻辑 10/10 断言通过;构建通过",
  262. "notes": "🚨 该条目历史:第一版提交序号(符合协议),被要求改为提交文字后**产生死循环**——提交公司名落进协议的“换关键词”路径,后端重新检索又返回同一批候选。根因:协议(api-chat.md)明确‘API 由序号映射到原候选’,文本这种输入形式被保留给 refine,语义上无法表达“我选这一个”。现按用户要求改为提交选项全部内容拼接文本。实现要点:QuestionCard 只回传 label 不回传 description,故协调器新增 lastInterruptPayload 留存补问数据,提交时按 label 反查候选重建文本。⚠️ 若后端仍按‘序号选中/文本则重新检索’处理,死循环会复现;届时的两条出路:①改回提交序号(序号取自当前显示顺序);②后端扩语义识别全字段文本。另修掉三个缺陷:原先只取 answers[0](多题丢失)、多选只取第一项、/^(\\d+)\\.\\s/ 规则误伤其它问卷。端到端未验证(排查时后端已完全不返回补问)。"
  263. },
  264. {
  265. "id": "dept-filter-restored",
  266. "priority": 60,
  267. "area": "policy",
  268. "title": "政策「更多」面板筛选还原为最初实现",
  269. "user_visible_behavior": "部门下拉仍是原来那 15 个固定部门,筛选按字面匹配。",
  270. "status": "passing",
  271. "verification": [
  272. "读 PolicyMatch.vue 断言 deptOptions 为固定的 15 个部门",
  273. "断言筛选比较为 String(item.source) === dept 的字面相等",
  274. "grep 确认无 matchDept / DEPT_KEYWORDS / normalizeDeptLabel 残留"
  275. ],
  276. "evidence": "已确认无残留,npm run build 通过",
  277. "notes": "曾先后尝试①从接口数据动态生成选项、②保留固定选项但加关键词归一化匹配,均被用户否掉。用户明确表态:暂时不做匹配,恢复成最初的。新接口返回的部门是全称(如青浦区科学技术委员会),与这 15 个简称字面不相等,因此选中部门会筛出空列表——这是按要求保持原逻辑的必然结果,不要自作主张去修。"
  278. },
  279. {
  280. "id": "file-upload",
  281. "priority": 70,
  282. "area": "upload",
  283. "title": "文件与图片上传",
  284. "user_visible_behavior": "用户能通过按钮 / 拖拽 / 粘贴上传图片与附件;上传成功后提问时,附件的 OSS 地址会**拼进 question 文本**发给后端(方案②,用户 2026-09-18 拍板);本地消息气泡与 DMS 记录只显示用户原文,不显示 URL。",
  285. "status": "passing",
  286. "verification": [
  287. "npm run build 通过",
  288. "harness/tools/verify-attachment-question.mjs —— 10 项断言:无附件原样返回(undefined/null/空数组/全空串)、有附件时原文在前且 URL 各一行、不添加自定义措辞、脏数据过滤、URL 去空格",
  289. "签名接口实测:POST //qingpu-data-api.metamaker.cn/common/qp_signed_url body {ext} → err_code 0,返回 file_url/host/key/policy(**无需鉴权**)",
  290. "浏览器端到端(用户 2026-09-18 确认无误):选文件 → 上传 → 提问,整条链路可用"
  291. ],
  292. "evidence": "纯函数断言 10/10 通过;构建通过;厂商签名接口 curl 实测可用;**浏览器端到端经用户确认无误**。",
  293. "notes": "方案②(用户 2026-09-18 拍板):把 OSS 地址拼进 question 文本 —— **不需要后端配合**。\n关键实现点:sendMessage 里拆成两个变量 —— `questionForBackend`(含附件地址,只发给后端)与 `text`(用户原文,用于本地气泡、会话标题、**DMS 记录**),避免 URL 出现在界面上。\n拼接格式:原文 + 换行 + 每条 URL 一行,**不加自定义措辞**;无有效 URL 时原样返回。\n入口:上传图片/附件两个按钮(原先注释,本轮恢复)+ **拖拽/粘贴(本来就是通的)**。\n限制:图片 20MB(jpg/jpeg/png/webp)、附件 50MB(pdf/doc/docx/txt/md)。\n\n⚠️ **存储与依赖**:上传走厂商的签名接口 `//qingpu-data-api.metamaker.cn/common/qp_signed_url`(实测**无需鉴权**),文件实际存在**厂商的 OSS bucket `heijing-products`(杭州)**,外链为 `https://prod.heijingai.com/qingpu/<uuid>.<ext>`。签名有效期约 15 分钟。\n签名地址已做成 env `VITE_UPLOAD_SIGN_URL`(四份 env 均写),换自建服务只改配置。\n\n⚠️ **未验证**:后端如何处理这段拼接的 URL(认不认、会不会抓取)未确认 —— 这是方案②的固有不确定性。\n\n备选方案(未采用):①后端扩展请求体接受 files/file_pos(最干净);③后端提供文件登记接口返回 file_id。\n\n✅ 2026-09-18 用户确认整条链路无误。\n边界说明:前端负责的是「按约定把附件地址拼进 question 发出去」;后端如何处理这段 URL(认不认、会不会抓取)属**后端行为**,不在前端验证范围内。"
  294. },
  295. {
  296. "id": "chat-markdown-format",
  297. "priority": 80,
  298. "area": "chat",
  299. "title": "回答的 markdown 分段",
  300. "user_visible_behavior": "多段内容正常分段,列表后面的说明行不被并进上一条列表项。",
  301. "status": "blocked",
  302. "verification": [],
  303. "evidence": "",
  304. "notes": "阻塞原因(方案未拍板):新后端 summary 文本混用单换行与空行分隔段落,而正文块是 marked + white-space: normal,markdown 下单个换行会被折叠,导致列表后的说明行被并进上一条列表项。前端可加归一化(非列表项之间的单换行提升为空行、连续列表项保持单换行),但需用户确认是否接受在适配层改动正文格式。前端渲染链路本身正常,证据:TextContent.vue 用 marked.parse,未做格式化转换。"
  305. }
  306. ]
  307. }