{ "meta": { "project": "zhaoshang-llm", "description": "青浦区营商智能助手(Vue 3 + TypeScript + Vite),含 AI 对话、政策卡片、文件上传、虚拟人/语音能力", "repo_root": "f:\\yysk\\AI_zhaoshang\\zhaoshang-llm", "last_updated": "2026-09-17", "status_legend": { "not_started": "还没碰", "in_progress": "当前正在做的那个(同一时间只能有一个)", "blocked": "有记录的阻塞问题,推不动", "passing": "验证通过,证据已记录" } }, "features": [ { "id": "multi-session-parallel-generation", "priority": 2, "area": "chat", "title": "按会话并行生成(修「另一会话提交导致原会话取消、新会话出错」)", "user_visible_behavior": "一个会话生成中,切到另一个会话可以正常发送并各自生成、互不干扰;停止只停当前会话那一轮;新建会话不再打断正在生成的会话;同一会话重复发送会被提示「该会话已有回答在生成中」;后端 409 等错误不再静默,会弹出提示。", "status": "passing", "verification": [ "npm run build 通过;npx vue-tsc --noEmit 本次涉及文件无新增类型错误", "harness/tools/verify-chat-task-utils.mjs —— 33 项断言:空/null 边界、发送决策三态、停止目标解析(含「当前在 B 时不会误停 A」)、归一化守卫、多会话并行组合语义、任务表不可变替换语义、**响应式陷阱回归**(断言 ref(new Map()) 会破坏对象同一性、shallowRef 不会、整体替换才触发响应式)", "harness/tools/verify-coordinator-multi-instance.mjs —— 11 项断言:两个协调器实例的事件完全隔离(停 A 时 B 零事件、B 出错时 A 零事件、计数各自独立)—— 并行化的地基", "全量回归:政策卡列表 31/31、卡字段 8/8、详情反查 10/10、政策库 21/21、判空口径 30/30、DMS 36/36;validate-harness 通过", "浏览器人工验证(用户 2026-09-17 确认):mock 测试按钮跑完按钮恢复、真实对话内容正常流式输出" ], "evidence": "两个脚本 33/33 与 11/11 通过;构建与类型检查通过;全量脚本绿;**浏览器人工验证通过(用户确认)**——含 mock 测试按钮与真实对话(两者都曾被响应式陷阱波及)。", "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 节)。" }, { "id": "policy-card-list-sidebar", "priority": 3, "area": "policy", "title": "卡片区「更多」→ 新侧边栏「政策列表」(本会话卡片聚合)", "user_visible_behavior": "点卡片区「更多 >>」打开侧边栏:标题「政策列表」,有搜索框与热门搜索,没有部门/分类筛选;内容为**本条回答里出现的政策卡片**(去重、按匹配度排序)—— 注:2026-09-18 用户调整,此前一度是「整个会话跨消息聚合」,已收回;详情面板的「返回政策事项列表」仍打开原来的惠企政策库列表。", "status": "passing", "verification": [ "npm run build 通过", "harness/tools/verify-policy-card-list.mjs —— 24 项断言:去重键(主键与占位符退化)、首见优先去重、降序+同分稳定、关键词过滤的六个检索面+大小写+空词、端到端组合", "残留检查:collectSessionPolicyCards / collectPolicyCardsFromMessages 在源码与入口零命中", "浏览器人工验证(用户 2026-09-18 确认):「更多」侧边栏只列本条回答的政策" ], "evidence": "回归脚本 24/24 通过;构建通过;聚合相关代码零残留;**浏览器人工验证通过(用户确认)**。", "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 唯一故能命中;不在本消息时直接展示被点条目本身)。" }, { "id": "policy-detail-index-mismatch", "priority": 4, "area": "policy", "title": "修复:同一政策的多个申报事项点「查看详情」内容一样", "user_visible_behavior": "两张卡片即使是同一政策下的不同申报事项,点「查看详情」也各自打开对应的那条内容,不再都跳到第一张。", "status": "passing", "verification": [ "npm run build 通过", "harness/tools/verify-policy-detail-index.mjs —— 10 项断言全通过。关键项:「点第 2 张 → 下标 1(修复前这里会返回 0)」、字段无法区分时返回 -1(不张冠李戴)、唯一命中仍采信、id/policy_id 可用、空值边界", "旧逻辑对照实证:同政策两卡片的场景下,点第 1 张与点第 2 张**都返回下标 0** —— 复现了用户看到的现象", "真实载荷探针 probe-policy-cards.mjs:确认后端 item 的 id/policy_id 恒为 undefined、card.name.text 两条相同" ], "evidence": "回归脚本 10/10 + 卡字段脚本 8/8 通过;旧逻辑对照复现了 bug;构建通过。⚠️ 浏览器未人工点过(见 notes)", "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 项,适配层字段)。" }, { "id": "remove-dev-auto-login", "priority": 6, "area": "auth", "title": "去掉开发环境硬编码账号的自动登录", "user_visible_behavior": "开发环境不再自动进入登录态——没登录就是访客态;要企业态需显式带 ?access_token= 或 ?credit_code=。", "status": "passing", "verification": [ "npm run build 通过", "grep 确认 src/ 与 index.html 里 devLogin / isDevLogin 零残留", "代码路径核对:initAuth() 现在只剩「有 access_token 走登录」「有 credit_code 直接拉企业信息」「都没有则保持未登录」三条分支,无环境判断" ], "evidence": "分支已整段移除,构建通过,grep 零残留。**浏览器人工确认(2026-09-17)**:用户在真实页面提问后,DMS 里落下的记录 `c_credit_code = 访客_68e82b71-a3ea-4332-b616-d174c4e9e4d4` —— 处于访客态、没有自动登录,与修复目标一致。", "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 的实测数据)。" }, { "id": "dms-chat-storage", "priority": 5, "area": "chat", "title": "会话、问答记录与反馈落地到 DMS", "user_visible_behavior": "用户提交问题时会话与该轮问答写入 DMS 的**问答记录(1889)**(一问一答一条:c_question 与 c_answer 各存一半,title 为问题文本);历史记录从 DMS 读取;点赞/点踩/取消写在该轮记录的 c_feedback_* 列上;未登录的访客记录以「访客_<访客id>」归属写入;DMS 不可用时聊天与本地历史不受影响。⚠️ **1887 助手会话不再写入**(2026-09-18 用户指示)——会话列表走本地,改名只改本地。", "status": "passing", "verification": [ "npm run build 通过", "harness/tools/verify-dms-payload-fields.mjs —— 9 项断言:**打桩 fetch 抓真实请求体**,断言写入字段集合「不多不少」且 title 的值正确(不需要 DMS token)", "浏览器人工点验(用户 2026-09-17 完成):在真实页面上提问并核对库中数据 —— 见 evidence 里引用的实际行", "代理链路实测:经 /dms-api/ 查询返回 202(token 已由代理注入);直连 DMS 不带 token 返回 208 无token", "DMS 残留复查:测试数据已清理(残留 0 行)", "harness/tools/verify-dms-chat-storage.mjs —— **43 项断言,打真实 DMS,43/43 通过**(2026-09-18 用新 token 复跑):一问一答一条、改名后 c_title 与系统 title 都跟随、四个必填字段写入且为空、记录 title=问题文本、反馈只改命中那一轮、删除连带清理", "harness/tools/verify-dms-payload-fields.mjs 第 4 节:**源码级断言**——composable 里不得有未注释的 upsertDmsSession / syncSessionTitleToServer 调用(1887 停用靠注释实现,防被顺手改回)" ], "evidence": "打真实 DMS 的 43/43 与打桩抓请求体的 10/10 均通过;构建通过;浏览器人工点验通过(用户 2026-09-17)。", "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。" }, { "id": "empty-content-agreement", "priority": 15, "area": "chat", "title": "统一「内容是否为空」的判空口径", "user_visible_behavior": "切会话或刷新后,未产生正文的消息不再误报「请求已取消」;只有真正被用户停止的消息才显示该提示。", "status": "passing", "verification": [ "npm run build 通过", "harness/tools/verify-empty-content-agreement.mjs —— 30 项断言:基准判定 9 例 + 不变量(两处必须一致)18 例 + 回归 3 例", "覆盖用例:空串 / 空白 / 只有 1 个进度块 / 多个进度块 / 只有 silence / 进度块+正文 / 纯正文 / 正文+进度块 / 只有 POLICY_TABLE 标记" ], "evidence": "验证脚本 30/30 通过;构建通过", "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 对正在生成的会话会跳过归一化)。若该措辞也不合适,需另定文案或改为不落文案。" }, { "id": "chat-api-migration", "priority": 10, "area": "chat", "title": "AI 对话迁移到新接口 POST /api/chat", "user_visible_behavior": "用户提问后能正常收到回答;政策类问题能出卡片;公司类问题能出补问候选。界面表现与原版一致。", "status": "passing", "verification": [ "npm run build 通过", "起本地服务端跑协议适配层契约测试(SSE 分片、CRLF、多行 data、409、断流、坏 JSON、过期 request_id、主动停止)", "对真实后端 192.168.2.23:8000 跑端到端:普通问答 / 政策推荐 / 公司补问 / 候选确认 / 翻页 / 取消" ], "evidence": "适配层契约测试 44/44 通过;真实后端多场景验证通过,行顺序 text→policy-table→text→ref-links,result.response 未被重复追加", "notes": "适配层在 src/components/api-chat-coordinator.ts,把新协议翻译成原界面本来就能渲染的内容标记(/POLICY_TABLE//)。旧协调器 src/components/stream-message-coordinator.ts 未改动、保留未用。请求体严格只发 {thread_id, question},多字段会 422。" }, { "id": "chat-rendering-order", "priority": 20, "area": "chat", "title": "回答内容顺序、换行与复制", "user_visible_behavior": "顺序为 正文 → 政策卡片 → 综合说明 → 参考资料;段落正常分段;复制出来的是纯可读文本,不含协议标记。", "status": "passing", "verification": [ "对真实后端取回内容,按帧喂给真实的 StreamXMLFilter,断言行顺序", "对含 scope/POLICY_TABLE/ref_links 的内容跑复制提取,断言标记全部被剔除" ], "evidence": "真实后端非 scope 行顺序 text→policy-table→text→ref-links(参考资料在最后);复制提取测试 7/7 断言通过", "notes": "复制用 copyableContent 正则剥离标记,不能用 getStreamPlainText——过滤器遇到