continuous-insight-plan.md 61 KB

持续联系 · AI 研判功能 设计文档

范围:ai-frontend/src/call/views/continuous/(持续联系页)新增「AI 研判」能力 目标:把当前查询结果交给专用 Agent 解读,产出有价值的线索 + 下一步思路 状态:P0 + P1 + P2 已实现(后端 BUILD SUCCESS + 57 个测试全过;前端 type:check / eslint / stylelint 本次改动文件全绿)。实施状态见 §10

关键决策速览:入口只做面板级主按钮(§2.1)|右抽屉面板(§2.2)|结构化围栏 + 卡片(§3)| 会话与消息入库,独立两张表 + 普通分页滚动加载(§4.1) |后端落 module/agent/insight/(§4.2)| 独立 Agent 实例池 + 动态 sysPrompt(§4.3)


1. 功能定位

一句话:在"持续联系对象汇总"表格旁边,开一个能读懂这批数据的研判助手。

它不做通用问答,只干四件事:

# 产出 说明
1 解读摘要 这批持续联系关系的整体特征:覆盖几人、最长/最短关系、月均密度分布、异常点在哪
2 价值线索(Clues) 分级(高/中/低)的可落地线索,每条带具体数字证据 + 可疑理由 + 建议核验动作
3 下一步思路(Next Steps) 可执行动作清单,每条指明"在哪做"(跳转到对应页面/工具)
4 追问下钻 对任一条线索继续追问,走普通对话流(复用现有富渲染块)

1.1 线索类型(沿用 AI_AGENT.md 领域术语)

线索类型 触发特征(从持续联系数据可判)
通联异常 深夜/凌晨占比显著偏高;月数很长但月内次数极低("养号式"维系);单向高频
攻守同盟 星型结构:多个对象 → 同一对方号码,或同一对方 ↔ 多个本方
身份不明对手 otherName = 未知对手 / otherPhone = 未知号码,但持续多月高频联系
反常断联 连续多月后在某月骤停(可能对应被采取强制措施、外逃、换号)
敏感日期命中 特殊日期仍保持联系(需交叉验证,激活 call 组下钻)
白手套 / 时空伴随 需跨域佐证(trans / track 组),由 Agent 自行按需激活工具组

最后两类不能只凭本表推断,Agent 必须先调工具取数再下结论——这一点写进提示词硬约束。


2. 交互与入口设计

2.1 入口:面板级主按钮(已定:本期只做主按钮)

编号 入口位置 语义 携带上下文
E1 主入口 「持续联系对象汇总」卡片标题栏右侧 —— AI 研判 主按钮(描边高亮 + i-mdi:auto-fix 图标) 分析当前整个查询结果集 完整查询条件 + 结果快照(按持续月数降序取 TOP N,默认 100)
E2 空态引导 未查询时的 EmptyState 下方一行小字 + 按钮 引导先查询 无
  • 表格操作列保持原样(仍是 buildTableActionColumn,只有「查看详情」,列宽不动)。
  • E1 禁用态:!hasSearched || !tableData.length,tooltip 提示原因("请先查询" / "当前无数据")。
  • 无默认模型 / 未打开案件 → 按钮禁用 + 明确文案,不发请求。

行级入口(单条关系对「AI 解读」)本期不做。 表结构保留 scope 字段(BATCH / 预留 PAIR),后续要加行级入口时只需补一个按钮, 后端 Service 与存储无需改动。

2.2 面板形态:三个方案对比

方案 形态 优点 缺点 结论
A 右侧抽屉 ✅ 从右滑出,宽 640px(可拖拽 480–1000) 表格保留在左侧,可边看边问;不丢上下文;实现成本最低 横向空间有限,长报告需滚动 推荐
B 内嵌右分栏 表格右侧常驻 380–420px 零点击即见 本页筛选条 + 6 列表格已很满,1680 宽以下横向挤压;AI 区大部分时间是空的 否
C 独立全屏页 跳 /call/continuous/insight 空间大,适合长报告 割裂,丢失"边看边问";且与 AI 数据分析页的会话混在一起(与"独立存储"诉求相悖) 否(保留为升级路径)
D 底部气泡/悬浮球 悬浮面板 存在感低 不适合需要长阅读与翻页表格的报告 否

采纳 A,并在抽屉右上角留「全屏」按钮:全屏复用同一组件铺满容器(CSS 切换),不新建页面。

2.3 抽屉内部五段结构(自上而下)

┌─ ① Header ──────────────────────────────────────────────┐
│ AI 研判 · 持续联系          [↻重新分析][▤历史][⛶全屏][✕] │
├─ ② ContextBar(固定,不可滚)────────────────────────────┤
│ ◈ 对象 3 人  ◈ 月数 ≥2  ◈ 2024-01-01~2024-12-31  ◈ 128 对 │
│ 已按持续月数降序取前 100 对进行研判                       │
├─ ③ Report / ④ Chat(滚动区)────────────────────────────┤
│ [结论摘要卡]                                             │
│ ┌ 线索卡 #1 ●高价值 通联异常 ─────────────────────────┐  │
│ │ 标题 / 证据 / 可疑点 / 建议核验                      │  │
│ │ [查看话单][地图][深挖][记入线索]                     │  │
│ └──────────────────────────────────────────────────────┘  │
│ ┌ 线索卡 #2 …                                       ┐  │
│ [下一步思路 · 编号清单 + 跳转按钮]                       │
│ [▸ 分析过程(默认折叠,含工具调用时间线)]                │
│ ─── 以下为追问对话(复用现有消息组件)───                 │
├─ ⑤ Composer(固定底)──────────────────────────────────┤
│ [预设追问 chip ×3]  ┌──────────────────────┐ [发送]      │
│                     │ 追问…                 │             │
└─────────────────────────────────────────────────────────┘

关键交互

  1. 打开即自动分析:不用敲字。抽屉开启 → 调 POST /insight/continuous/sessions → 立刻 POST /insight/continuous/stream(首轮 message 为空,服务端注入固定首轮指令)。
  2. 首轮流式体验:骨架屏 → 过程时间线("正在统计分布 / 正在比对同案基线…")→ 逐段吐字。过程区默认折叠,不喧宾夺主。
  3. 结构化优先:首轮由 insight` 围栏输出结构化 JSON → 前端渲染成卡片;模型若未吐围栏则自动降级为 Markdown 报告(不空屏)。
  4. maskClosable=false:防止误点遮罩丢流;关闭时若有流在跑,先中止(复用现有 AbortController 语义)。
  5. 条件变更不打断:抽屉开着时用户改了筛选条件并查询 → ContextBar 顶部浮出「条件已变更,可重新分析」提示条,点「重新分析」才开新会话。

3. 结构化输出契约(```insight 围栏)

这是"线索 + 下一步思路"能被渲染成可用 UI(而不是一坨文字)的前提。

{
  "summary": "本次识别 128 对持续联系关系,覆盖 7 名对象;最长一对连续 14 个月……",
  "metrics": [
    { "label": "关系对", "value": "128" },
    { "label": "平均持续", "value": "4.2 月" },
    { "label": "身份不明对手", "value": "9" }
  ],
  "clues": [
    {
      "id": "C1",
      "level": "high",                    // high | medium | low
      "type": "通联异常",
      "title": "张三 ↔ 138****1111 连续 11 个月深夜联系",
      "subjects": { "self": "张三", "otherName": "李四", "otherPhone": "138xxxx1111" },
      "evidence": [
        "连续 11 个月(2024-01 ~ 2024-11)",
        "通话 218 次,62% 落在 23:00–03:00"
      ],
      "reason": "深夜占比显著高于同案关系对均值 12%,且持续时长与通话密度背离常理",
      "nextActions": ["拉取该对全部话单核对基站落点", "核验 138****1111 的实名人"],
      "links": [
        { "label": "查看话单", "action": "openCallRecord", "params": { "personName": "张三", "otherPhones": ["138..."], "startDate": "2024-01-01", "endDate": "2024-11-30" } }
      ]
    }
  ],
  "nextSteps": [
    {
      "order": 1,
      "title": "对 3 个高价值关系对做基站时空碰撞",
      "purpose": "确认持续联系是否伴随线下见面",
      "entry": { "label": "时空碰撞", "route": "/track/meet" }
    }
  ],
  "caveats": ["本表为汇总口径,未含通话时长与基站信息;结论需下钻话单核验"]
}

落地要点

  • 后端在新 Agent 的 sysPrompt 里追加专属渲染契约(不复用也不污染 AgentService#appendRenderPrompt 的通用契约)。
  • 前端扩展点(三处小改,纯增量):
    • ai/utils/constants.ts:BlockKind 加 'insight';FENCE_BLOCK_MAP 加 insight: 'insight'
    • ai/utils/scanner.ts:围栏识别自动覆盖(按 map 查表)
    • ai/components/BlockRenderer.vue:加一个分支 → 新组件 blocks/InsightBlock.vue
  • 校验与降级:解析后做轻量结构校验(clues 是数组、每条有 title/evidence);不合法或未解析 → 保留原始 Markdown 文本渲染,绝不白屏。
  • SSE 额外下发一帧 insight(结构化对象):前端不必等 token 拼完就能先渲染卡片骨架,体验更稳。

4. 后端设计

4.1 会话存储:数据库表设计(已定:入库,独立于现有 agent 三表)

本节是本设计的重点。会话与消息入库,但不复用 agent_chat_session / agent_message, 另建两张独立表 agent_insight_session / agent_insight_message。 「隔离」由独立表 + case_id 归属 + 外键级联 + 强制 where 建立; 「滚动加载」由普通分页接口 + 会话内单调 seq 稳定排序支撑。

4.1.1 隔离策略

本项目已移除用户体系(AgentChatServiceImpl.RUNTIME_USER_ID = "default"),因此不需要用户维度。 实际生效的是三层:

层 隔离对象 靠什么
1. 表级 与通用对话分开 独立表名 agent_insight_*,与 agent_chat_session / agent_message 物理分离;两边 Service / Mapper 零交叉引用,也不共用 AgentService 的会话方法
2. 案件级 硬边界 会话行必带 case_id;所有列表查询强制 WHERE case_id = ?;案件删除时 ON DELETE CASCADE 连带清理
3. 会话级 分析单元 消息行必带 session_id,且查询必须同时命中 case_id(见下方不变量)

三条不变量(写成测试,不靠 code review):

  1. 不存在只按 session_id 查消息的 SQL —— 消息列表/详情一律 WHERE session_id = ? AND case_id = ?。因此消息表冗余一列 case_id,让这条约束在 SQL 层面就能写出来。
  2. 写消息前先校验会话归属 —— 先取 agent_insight_session.case_id 与入参比对,不一致直接 403,再落库。
  3. biz_type 恒为常量 —— 首期恒 'CALL_CONTINUOUS',作为「同表将来扩其它研判场景」的预留维度; 所有查询默认带上它,避免将来多场景共表时串数据。

为什么消息表冗余 case_id(违背范式):只靠 session_id JOIN 会话表也能过滤 case_id, 但那让每个新写的查询都有「忘记带 case_id」的机会。冗余一列 + 联合索引,代价极小, 换来「越权查询在 SQL 层面就写不出来」的强约束 —— 这正是文件方案(§4.1.8 对比)拿不到的保护网。

4.1.2 表结构

DDL 落 sql/(沿用项目现有目录与命名习惯)。字段风格对齐现有的 agent_chat_session / agent_message: 时间列用 create_at / update_at(LocalDateTime,@TableField(fill = FieldFill.INSERT / INSERT_UPDATE)), 半结构化数据用 jsonb(@TableField(typeHandler = JsonbTypeHandler.class, jdbcType = JdbcType.OTHER))。 字段命名也与 agent_message 保持一致(role / content / content_type / token_count / metadata / tool_events / message_type / reply_to_message_id / starred),便于对照与复用既有前端解析逻辑 —— 本表只新增 seq / case_id / output_json / status 四个字段。

-- ============================================================
-- AI 研判 · 会话表(独立于 agent_chat_session)
-- ============================================================
CREATE TABLE agent_insight_session (
  id                BIGINT       PRIMARY KEY,               -- 应用侧生成(见 §4.1.3 注意点)
  case_id           BIGINT       NOT NULL,                  -- ★ 案件级硬隔离边界
  biz_type          VARCHAR(32)  NOT NULL DEFAULT 'CALL_CONTINUOUS',
  scope             VARCHAR(16)  NOT NULL DEFAULT 'BATCH',  -- BATCH | PAIR(预留)
  agent_row_id      BIGINT,                                 -- 使用的 agent 配置行
  model_id          BIGINT       NOT NULL,                  -- 创建时定的默认模型,会话内不变
  title             VARCHAR(128),                           -- 「持续联系研判 · 2024-12-01 14:32」
  context           JSONB        NOT NULL,                  -- ★ 查询条件 + 结果快照(动态 sysPrompt 的数据源)
  context_hash      VARCHAR(64)  NOT NULL,                  -- context 的 sha256,用于同条件复用/去重提示
  sys_prompt        TEXT         NOT NULL,                  -- ★ 实际使用的完整 sysPrompt,会话内不可变
  message_count     INT          NOT NULL DEFAULT 0,        -- 消息条数(列表页直读,不 count(*))
  last_seq          BIGINT       NOT NULL DEFAULT 0,        -- ★ 会话内消息序号水位(原子分配用)
  total_tokens      BIGINT       NOT NULL DEFAULT 0,
  last_message_at   TIMESTAMP,                            -- 最后活动时间(列表排序用)
  pinned            BOOLEAN      NOT NULL DEFAULT FALSE,
  status            VARCHAR(16)  NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | STREAMING | ARCHIVED
  create_at         TIMESTAMP  NOT NULL DEFAULT now(),
  update_at         TIMESTAMP  NOT NULL DEFAULT now()
);
CREATE INDEX idx_ais_case_list ON agent_insight_session (case_id, biz_type, last_message_at DESC);
CREATE INDEX idx_ais_ctx_hash  ON agent_insight_session (case_id, context_hash);

COMMENT ON TABLE  agent_insight_session IS 'AI研判会话(独立存储,不复用通用对话表)';
COMMENT ON COLUMN agent_insight_session.context IS '查询条件+结果快照,动态系统提示词的数据源';
COMMENT ON COLUMN agent_insight_session.last_seq IS '会话内消息序号水位,消息 seq 由它原子分配';

-- ============================================================
-- AI 研判 · 消息表
-- ============================================================
CREATE TABLE agent_insight_message (
  id                  BIGINT      PRIMARY KEY,
  session_id          BIGINT      NOT NULL,
  case_id             BIGINT      NOT NULL,                 -- ★ 冗余,保证消息查询永远可带 case_id
  seq                 BIGINT      NOT NULL,                 -- ★ 会话内递增,分页排序列
  role                VARCHAR(16) NOT NULL,                 -- user | assistant | system
  content             TEXT,                                 -- 正文
  content_type        VARCHAR(16) NOT NULL DEFAULT 'text',  -- text | insight
  message_type        VARCHAR(16) NOT NULL DEFAULT 'chat',  -- chat | first_round(首轮自动分析) | followup
  metadata            JSONB,                                -- 思考过程/中断标记/耗时等轻量字段
  tool_events         JSONB,                                -- ★ 工具调用明细(大,列表查询不 select)
  output_json         JSONB,                                -- ★ ```insight 结构化产物(首轮 assistant 才有)
  token_count         INT         NOT NULL DEFAULT 0,
  reply_to_message_id BIGINT,                               -- 追问指向的首轮消息(UI 分组用)
  starred             BOOLEAN     NOT NULL DEFAULT FALSE,
  status              VARCHAR(16) NOT NULL DEFAULT 'DONE',  -- DONE | INTERRUPTED
  create_at           TIMESTAMP NOT NULL DEFAULT now(),

  CONSTRAINT uk_aim_session_seq UNIQUE (session_id, seq),
  CONSTRAINT fk_aim_session FOREIGN KEY (session_id)
      REFERENCES agent_insight_session (id) ON DELETE CASCADE
);

-- 线索聚合(为「线索推送」页预留):按案件捞有结构化产物的消息
CREATE INDEX idx_aim_output ON agent_insight_message (case_id, create_at DESC)
  WHERE output_json IS NOT NULL;

COMMENT ON COLUMN agent_insight_message.seq IS '会话内单调序号,分页排序列(勿用 create_at/id 排序)';
COMMENT ON COLUMN agent_insight_message.case_id IS '冗余自会话表:保证消息查询永远可带 case_id 过滤';

字段取舍说明

决策 理由
不给工具事件单独建表 工具事件只是「一段挂在消息上的展示数据」,从来没有独立查询它的需求;jsonb 一列足够。单独建表会引入「逐条追加/更新事件行」的额外写放大,收益为零
sys_prompt 存全量文本 它是「同一条会话为什么给出这个结论」的复现依据(含口径说明)。不通过任何接口下发,仅运维/追溯读
大字段不拆表 tool_events / output_json 留在同表:PG 大字段走 TOAST 行外存储,列表查询不 select 它就不会被读出来,性能损失可忽略;拆表反而让「拉一条消息 + 拉它的事件」变成两次往返
不存 token 级增量 正文只保留终态(见 §4.1.6),避免流式过程中每分钟数百次 UPDATE
idx_aim_output 用部分索引 只有首轮 assistant 消息有 output_json,占总量不到 10%;部分索引体积小、命中准

4.1.3 排序为什么必须有 seq(而不是 id / create_at)

分页接口用普通分页(page / pageSize),但 ORDER BY 必须落在一个可靠的列上 —— 本项目两个现成方案都不行:

候选 为什么不能用
id 实体虽然标了 @TableId(type = IdType.ASSIGN_ID),但现有代码在 AgentChatServiceImpl 里显式 setId(generateId()) 覆盖掉,而 generateId() = UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE —— 随机、非单调。拿它 ORDER BY 会让消息顺序彻底乱掉
create_at 首轮一次落 2 条、追问落 1 条,同毫秒极常见 → ORDER BY create_at 顺序不确定,翻页会重复或漏行;时钟抖动还会让顺序跳变
seq ✅ 会话内严格单调 + 唯一(uk_aim_session_seq),分页顺序稳定、不重不漏

顺带说明:即使某些场景下 id 走的是 MP 雪花(时间有序),它也不是会话内的连续序号 —— 「这个会话的第几条消息」这个语义只有 seq 能表达。而且分页顺序若依赖 ID 生成策略, 换个生成器就悄悄坏掉,属于埋雷。

seq 的原子分配(并发写同一会话不会重号,也不需要应用侧加锁):

-- 与 INSERT 消息放在同一个事务里,一行完成「加水位 + 拿号」
UPDATE agent_insight_session
   SET last_seq = last_seq + 1, update_at = now()
 WHERE id = #{sessionId}
RETURNING last_seq;

依赖 PG 的行级排他锁:并发时第二个事务阻塞在这条 UPDATE 上,直到第一个提交,因此拿到的号天然不重复。 比「先 SELECT max(seq) 再 +1」安全 —— 后者在 READ COMMITTED 下会重号。

会话 ID 复用同款生成器:agent_insight_session.id / message.id 沿用项目现有的 generateId() 即可。 它是随机的,只做主键、不做排序 —— 这正是要用 seq 单独扛排序的原因。

4.1.4 查询与索引映射

场景 SQL 要点 用到的索引
历史列表(抽屉「历史」下拉) WHERE case_id=? AND biz_type=? ORDER BY pinned DESC, last_message_at DESC LIMIT 50,不 select context / sys_prompt idx_ais_case_list
会话详情 WHERE id=? AND case_id=? PK + 归属校验
消息列表(首屏,最新一页) MP Page + WHERE session_id=? AND case_id=? ORDER BY seq DESC,取第 1 页;不 select 大字段 uk_aim_session_seq 反向扫描
消息列表(上滑加载更早) 同上,page = 2, 3, ...(顺序仍是 ORDER BY seq DESC) 同上
单条消息 trace SELECT tool_events, metadata FROM agent_insight_message WHERE id=? AND case_id=?,独立接口按需拉 PK
线索聚合(预留) WHERE case_id=? AND output_json IS NOT NULL [AND 分级条件] ORDER BY create_at DESC idx_aim_output
删除会话 DELETE FROM agent_insight_session WHERE id=? AND case_id=?,消息由 FK 级联 PK + FK

列表查询绝不 select tool_events / output_json。实测单条 assistant 消息的 toolEvents.result 最长可达 297KB(2026-09-17 工具详情排查记录);20 条一起 select 就是几 MB。 Mapper 里显式列清单或 resultMap,禁止 SELECT *; 前端点开「分析过程」时才走 trace 接口单独取。

唯一索引 uk_aim_session_seq (session_id, seq) 已经能支撑 seq DESC 分页排序 —— PG 可以反向扫描 B-tree,不需要再建 (session_id, seq DESC) 冗余索引。

4.1.5 消息分页接口契约

用普通分页(page + limit),复用项目既有的分页约定与 MyBatis-Plus 的 Page —— 不引入游标参数。

GET /insight/continuous/sessions/{id}/messages?caseId=&page=1&limit=20
参数 语义
page 页码,从 1 开始(第 1 页 = 最新的 N 条),默认 1
limit 每页条数,默认 20(message-page-size),上限 100
排序 固定 ORDER BY seq DESC(服务端写死,忽略 orderKey / sort)

参数名用 page + limit,与项目既有约定一致(common/base/Query.java:page 默认 1、limit 默认 20)。 查询入参可直接继承 Query 或另写 InsightMessageQuery extends Query,避免再造一套命名。

第 1 页返回最新的 N 条、第 2 页是更早的 N 条,前端把每页 unshift 到列表头部。

返回结构(直接用 MP Page 的形状,不另造壳):

{
  "records": [ /* InsightMessageVO[],按 seq 降序;前端 unshift 前先 reverse 成升序 */ ],
  "total": 42,      // MP 自动 count(*)
  "size": 20,
  "current": 1,
  "pages": 3
}

total 由 MP 的 count 查询得出;会话列表页显示「N 条消息」仍读 session.message_count,两者用途不同、互不替代。

⚠️ 方言注意点:MybatisPlusConfig:20 注册分页插件时传的是 DbType.DUCKDB。 PG 与 DuckDB 的分页 SQL 都是 LIMIT ? OFFSET ?,本表在 PG(master)上可以正常分页; 但若实测出现方言报错,就直接在 XML 里手写 ORDER BY seq DESC LIMIT #{limit} OFFSET #{offset} + 单独 count(*),不折腾插件。

前端滚动实现(InsightMessageList.vue):

  • 容器挂 @scroll(节流 100ms);当 scrollTop < 80 && page < pages && !loading 时 page += 1 拉下一页 → records.reverse() 后 unshift(...),再补偿滚动位置(先记 scrollHeight,插完 scrollTop += 新 - 旧),防止内容跳顶。
  • 首屏/切会话时重置 page = 1,避免带着上个会话的页码请求。
  • 不上虚拟列表(消息含富块、行高不定,成本高于收益;单会话几十条的量级不需要)。
  • 不做轮询补新消息:流式期间靠 SSE 增量;流结束后若担心漏消息,用户手动刷新/重开抽屉即可 —— 不做 5s 轮询,省一层定时器与状态(见 §5.4)。
  • ⚠️ 已知取舍:消息持续追加时,页码分页会出现「同一条消息在两页都出现」的轻微重复(新消息把内容往后挤)。本项目单会话几十条、且追加只发生在自己发问时,实际影响可忽略;前端按 id 去重兜底即可。

4.1.6 并发与一致性

场景 机制
同一会话并发两条流(两个页签 / 重复点「重新分析」) 双保险:先 ConcurrentHashMap<String, Lock> 拦一道;再 UPDATE agent_insight_session SET status='STREAMING' WHERE id=? AND status='ACTIVE',affectedRows=0 → 返回 409 该分析正在进行中。流结束/异常在 finally 复位 ACTIVE
seq 并发分配 由 DB 行锁解决(§4.1.3),不需要应用锁
消息落库粒度 消息粒度,不是 token 粒度:流开始 → insert 一条 role=user;流结束 → insert 一条完整 assistant;中止/异常 → insert 一条 status=INTERRUPTED 的 assistant(保留已生成正文)。不做 token 级 UPDATE,否则写放大几十倍
流中预览 正文只在前端内存里长,落库是流末一次写入。刷新页面不丢(最后一条是已完成消息),与现有 /chat 的 partial 落库语义一致
事务边界 「UPDATE last_seq ... RETURNING + INSERT message + UPDATE message_count」在同一事务;失败则整体回滚,seq 不留空洞
配额 单案会话数上限(默认 500):新建前 SELECT count(*)(idx_ais_case_list 可覆盖),超限拒绝并提示清理历史。DB 方案下不再需要「目录总大小」类配额
留存 可配 retentionDays(默认 0 = 不自动清理);开启后按 last_message_at 清理,消息随 FK 级联删除

4.1.7 配置项

zsjz:
  insight:
    max-sessions-per-case: 500
    retention-days: 0                 # 0 = 不自动清理
    message-page-size: 20             # 消息分页 limit 默认值(Query.limit 默认也是 20)
    message-page-size-max: 100        # limit 上限,超出直接截断(不报错)
    session-lock-timeout-ms: 3000

落 InsightProperties(@ConfigurationProperties("zsjz.insight")),照 EtlProperties / RagProperties 的写法。

4.1.8 入库换来的东西(与文件方案的账)

文件方案得自己补的 入库后 DB 直接给
目录层级 + 路径解析 + 越界校验 case_id 列 + 强制 where 条件
会话锁 / 索引锁 / 原子替换 DB 事务 + 行级锁
手写 index.json / 配额 / 孤儿目录 / tmp 清理 索引、级联删除、count(*)
「同条件是否已分析过」要扫目录比对 context_hash 一列直接比对
跨案件线索聚合要遍历 output.json 走 idx_aim_output 部分索引,一条 SQL

代价是多两张表 + 一段 DDL,以及一条要注意的钩子: ON DELETE CASCADE 只保证「删会话行时消息一起删」, 不保证业务上会去删这张表 —— 若案件删除是软删(status=DELETED),需要在查询侧过滤 case_id 或在案件删除事件里显式清理。实现时先确认案件删除的实际语义。

4.2 代码落位

决策:放在 module/agent/insight/ 子包,不新建 module/insight。

理由:AI_AGENT.md §4 规定 <domain> 为固定取值集合(agent/call/dm/.../trans),insight 不在其中;且该能力与 agent 子系统强耦合(复用 HarnessAgent、AgentStateStore、工具与 SqlResultStore)。「独立存储」的诉求由独立的两张表满足,模块不必强行拆。

ai-server/src/main/java/com/zsjz/ai/module/agent/insight/
├─ controller/  ContinuousInsightController            # /insight/continuous/**
├─ service/     ContinuousInsightService / ...Impl
├─ agent/       InsightAgentFactory                     # ★ 独立实例池 + 动态 sysPrompt 装配
├─ prompt/      InsightPromptBuilder                    # ★ 动态系统提示词拼装
├─ context/     InsightContextBuilder / InsightContext  # 查询条件 + 结果快照 → context 快照
├─ entity/      AgentInsightSession / AgentInsightMessage          # ★ 独立两张表
├─ mapper/      AgentInsightSessionMapper / AgentInsightMessageMapper
└─ dto/vo/      InsightCreateDTO / InsightStreamDTO / InsightSessionVO
                InsightMessageQuery(继承 Query,page/limit) / InsightMessageVO / InsightContextVO
                # 分页返回直接用 MP 的 Page<InsightMessageVO>,不另造 Page DTO

ai-server/src/main/java/com/zsjz/ai/module/agent/insight/config/  InsightProperties
ai-server/src/main/resources/mapper/insight/AgentInsight*.xml      # 显式列清单,禁 SELECT *
ai-server/src/main/resources/prompts/insight/CALL_CONTINUOUS_INSIGHT.md   # 静态人设 + 输出契约
sql/  insight_agent_tables.sql                                     # 两张表 DDL(§4.1.2)

归属过滤统一收在 Mapper 方法签名里 —— 方法名形如 selectByIdAndCase / selectPageBySessionAndCase / insertMessage(内部先校验会话归属)。Service 只调这些方法,不自己拼 where, 这样「忘了带 case_id」在编译期就写不出来。 不再加一层 InsightStore:DB 方案下 Mapper 已承担这个职责,再加一层只是空转发。

4.3 ★ 动态系统提示词机制

这是本功能的技术核心,也是与现有 Agent 最大的差异点。

4.3.1 提示词结构

sysPrompt(session) =
      ① 静态人设 + 输出契约       ← prompts/insight/CALL_CONTINUOUS_INSIGHT.md(可运维改,不发版)
    ⊕ ② 动态数据上下文块          ← InsightContextBuilder 按 context 快照实时拼装
    ⊕ ③ 专属渲染契约块            ← ```insight 围栏的 JSON Schema 说明
    ⊕ ④ RAG 指引(可选)          ← 复用 ragSchemaService.isAvailable()

② 动态数据上下文块的内容:

## 本次分析的数据上下文(系统自动注入,勿向用户复述)

### 案件与分析模式
- 案件:${caseName}(caseId=${caseId})
- 模式:${BATCH=对本批结果集整体研判 | PAIR=对单条关系对深度研判(预留)}

### 用户查询条件
- 分析对象:${personNames 或「全案对象人员」}
- 持续联系月数阈值:≥ ${monthVal}
- 联系日期范围:${startDate} ~ ${endDate}
- 对方筛选:${otherName / otherPhone / 无}
- 结果规模:命中 ${total} 对,本次提供 ${provided} 对(按持续月数降序 TOP ${topN})

### 数据口径(务必按此理解,禁止自创口径)
- pairContMonthCount:该对关系「连续」通话的月份数;连续 = 相邻月份不间断,
  断月会切成多段分别计数,本表为各段求和
- pairTotalCalls:上述连续段内的通话总次数
- continuousStartMonth / continuousEndMonth:连续段起止月份
- otherName = "未知对手" / otherPhone = "未知号码":原始话单该字段为空,
  **这本身是一条线索**(长期联系但身份不明)
- 本表一行 = 一对(本方, 对方)的**汇总**,不是单通电话;
  想知道单通明细必须调用 get_call_records

### 结果数据(TOP ${topN},按持续月数降序)
| 本方 | 对方 | 对方号码 | 连续月数 | 通话次数 | 起止月份 |
|------|------|----------|----------|----------|----------|
| …    | …    | …        | …        | …        | …        |

4.3.2 生成时机与实例池(关键工程决策)

问题 决策 原因
sysPrompt 何时生成 创建会话时由服务端拼好,与 context 快照一起写进 agent_insight_session.sys_prompt;会话内不再变化 保证同一会话内模型行为稳定;追问时上下文与首轮一致,避免 sysPrompt 抖动导致结论翻烧饼。落库也便于「这个结论是怎么来的」事后复现
Agent 实例怎么管 独立池,key = insight-s{sessionId},LRU 64 / 空闲 30 分钟回收 ★ 必须新开池:现有 agentPool 的 key 是 w{ws}-a{agent}-m{model},没有 sysPrompt 维度 —— 复用会让动态提示词在不同会话间串台
状态槽位 复用同一 AgentStateStore,槽位 = (default, insight-{sessionId}) 记忆按会话隔离,与实例无关(沿用官方"sandboxed stateless engine"模式)
上下文变更 用户重新查询后点「重新分析」→ 新建会话,不改老会话的 sysPrompt 语义干净:一次分析 = 一个会话 = 一份上下文

4.3.3 Agent 构建参数

项 取值 说明
name call-continuous-insight —
model 默认 LLM 模型:AgentModelService#getDefaultModelId() 取不到 → 抛 ServerException(400, "未配置默认模型…"),前端提示去模型管理页设置
sysPrompt 见 §4.3.1 动态
maxIters 6(现有默认通常更高) 解读任务,避免长循环烧 token
基础设施工具 search_table_schema(RAG 可用时)、execute_sql、list_tables、render_chart、render_graph 与现有 Agent 同源
业务工具组 默认装备 call 组(含 stat_call_continuous_summary/detail、话单明细),其余组(person/trans/track/graph)由模型按需 reset_equipped_tools 激活 交叉验证能力保留,但不把 24 份 person schema 每轮塞进上下文
enableMetaTool true 允许模型自行切组
中间件 不挂 IntentMiddleware / FollowupMiddleware 意图识别是通用对话的产物;Followup 的"下一步建议"与本功能自带的 nextSteps 重复,会造成双重建议
stateStore 复用 AgentStateStore —
workspace PathConst.ROOT_PATH/.agentscope 与现有一致

需要的向后兼容小改造:AgentToolRegistry#registerBusinessTools(Toolkit) → 增加重载 registerBusinessTools(Toolkit, List<String> activeGroups),原方法委托 DEFAULT_ACTIVE_GROUPS。 createGroup 的 active 参数由传入的 activeGroups 决定。现有调用方零改动。

4.4 接口清单

方法 路径 说明
POST /insight/continuous/sessions 创建分析会话。入参:caseId、scope(本期恒 BATCH)、filters(CallContinuousQuery 可见字段)、rows(结果快照)、topN。返回 sessionId + 上下文摘要(供 ContextBar 渲染)
POST /insight/continuous/stream SSE。入参 { sessionId, caseId, message? };首轮 message 为空 → 服务端注入固定首轮指令。进入前抢会话锁(应用锁 + status 状态位),抢不到返回 409
GET /insight/continuous/sessions?caseId=&scope= 历史分析记录(抽屉「历史」下拉)——WHERE case_id=? 单表查,不 select context / sys_prompt
GET /insight/continuous/sessions/{id}?caseId= 会话详情(含上下文摘要)。按 id + case_id 查,不匹配即 404
GET /insight/continuous/sessions/{id}/messages?caseId=&page=&limit= ★ 普通分页消息列表(滚动加载,契约见 §4.1.5)。page/limit 沿用 Query 约定,ORDER BY seq DESC 服务端写死;返回轻量字段,不含 tool_events / output_json
GET /insight/continuous/messages/{messageId}/trace?caseId= ★ 按需拉单条消息的工具明细 + 思考过程(点开「分析过程」时调)
DELETE /insight/continuous/sessions/{id}?caseId= 删除会话行(消息随 FK 级联)+ 清 agent 池
GET /insight/continuous/clues?caseId=&level= ★ 线索聚合,查该案件下带 output_json 的消息,为「线索推送」页预留

所有读接口一律 id + case_id 双条件。DB 方案下 case_id 就是一个 where 条件, 不存在文件方案那种「拼进路径」的越权风险;但 不允许任何方法只按 session_id 查(§4.1.1 不变量 1)。 若嫌每个接口都带 caseId 啰嗦,可在 Mapper 层把它做成强制参数 —— 不给默认值。

SSE 事件与 /chat/stream 完全同构:tool_call / tool_input / tool_result / token / thinking / done / error, 仅新增 insight 一帧(结构化产物解析成功时下发)→ 前端 SSE 解析器可 100% 复用。

返回形态:/insight/** 与 /chat/** 一致采用裸返回(不走 Result<T> 包装), 理由:前端 aiHttp.*Raw(isTransformResponse:false)可直接复用,避免两套取值逻辑。 (这是对 AI_AGENT.md §4「新增代码一律用 Result」的有意偏离,与既有 /chat/** 保持同一风格,文档化在此。)

4.5 静态人设提示词要点(prompts/insight/CALL_CONTINUOUS_INSIGHT.md)

人格:资深通联行为研判员(不是通用助手,不寒暄、不复述问题)。

硬性输出契约:

  1. 先输出唯一一个 insight` 围栏 JSON(结构见 §3),再输出一段 Markdown 供人阅读。
  2. 线索 3–6 条;每条 ≥1 条可核验的数字证据(必须引用上下文表格中的具体数值)。 禁止"可能存在""疑似有问题""建议进一步核实"这类无信息量表述。
  3. 每条线索必须给出「下一步怎么核」(1–2 个动作)。
  4. 无有效线索时必须显式说明"本轮未发现异常,原因是……",禁止编造凑数 —— 研判工具的底线。
  5. 禁止编造未提供的数据(人数、次数、日期、金额);需要更多数据必须先调工具取数再下结论。
  6. 术语用领域词:通联异常、攻守同盟、白手套、时空伴随。
  7. 不确定的结论标注置信度 high/medium/low。
  8. 引用号码时按 138****1111 形式脱敏展示。

5. 前端设计

5.1 文件落位

ai-frontend/src/
├─ call/api/insightApi.ts                                   # 会话 CRUD + SSE
├─ call/api/types/insight.ts                                # InsightSession/Message/Payload/SSE 回调类型
├─ call/views/continuous/components/
│   ├─ ContinuousInsightDrawer.vue                          # ★ 抽屉主体
│   ├─ InsightContextBar.vue                                # 上下文芯片条 + 「条件已变更」提示
│   ├─ InsightReport.vue                                    # 报告容器(摘要卡 + 线索列表 + 下一步 + 过程折叠)
│   ├─ InsightMessageList.vue                               # ★ 消息列表 + 滚动加载(普通分页 + 滚动位置补偿)
│   ├─ InsightClueCard.vue                                  # 单条线索卡
│   ├─ InsightNextSteps.vue                                 # 下一步思路清单
│   └─ InsightHistoryPopover.vue                            # 历史分析记录
├─ call/views/continuous/hooks/
│   ├─ useContinuousInsight.ts                              # ★ 流式状态机(独立,不碰全局 store)
│   └─ useInsightContext.ts                                 # filters + tableData → 上下文快照
├─ ai/api/sse.ts                                            # ★ 从 chatApi.ts 抽出 SSE 解析器(纯重构)
├─ ai/components/blocks/InsightBlock.vue                    # 新渲染块
└─ ai/utils/{constants.ts,scanner.ts} + components/BlockRenderer.vue   # 三处增量扩展

为什么新写 hook 而不复用 ai/store/chatStream.ts:该 store 是 defineStore 单例, workspaceId / sessions / messages 均为全局态,AI 数据分析页正在使用;抽屉里再驱动它会把两处消息流搅在一起。 抽屉用独立组合式 hook 持状态,但复用 chatApi 的 SSE 解析器 —— 把 createParser / streamChat 抽到 ai/api/sse.ts,chatApi.ts 与 insightApi.ts 共用(纯重构、零行为变化)。

5.2 continuous/index.vue 改动点

位置 改动
columns 操作列 不改动 —— 仍是 buildTableActionColumn(只有「查看详情」)
panel-header 右侧新增 AI 研判 主按钮(实心 type="primary",不用 disabled:条件不满足时点击给出原因 —— 见 §10.7 ③)
模板末尾 新增 <ContinuousInsightDrawer v-model:open="insightOpen" :filters="filters" :rows="tableData" />
<style> 抽屉样式与页面 .continuous-page 变量对齐,复用 trans-analysis-table-ui.less 的色板与圆角

图标(踩坑点):AI 研判 用 i-mdi:auto-fix、线索卡等级用 i-mdi:alert-decagram-outline 等, 必须在 uno.config.ts 的 safelist 中登记(AI_AGENT.md §8 坑 1 —— menu.json / 动态拼类名不会被扫描)。

5.3 状态机(useContinuousInsight)

idle → creating(建会话)→ streaming(首轮/追问)→ done
                    ↘ error(模型未配置 / 网络 / 超限 / 409 进行中)
  • 持有:sessionId、messages(按 seq 升序)、page / pages / total、insightPayload(结构化产物)、toolSteps、thinking、streaming、abortController
  • 消息加载:loadPage(page) 拉第 N 页(ORDER BY seq DESC)→ reverse() 后 unshift + 滚动补偿;首屏与切会话都走 loadPage(1)
  • 复用现有富内容解析:parseMessageBlocksIncremental / parseToolEvents / parseThinkingFromMetadata (节流重扫 + 已完成块冻结,避免长回答 O(n²))
  • 追问走同一 sessionId;stop() = abortController.abort()

5.4 降级与兜底

场景 行为
无默认模型 抽屉内提示「请先在 AI 模型管理中设置默认模型」+ 跳转按钮,不发请求
未打开案件(无 caseId) 入口禁用 + tooltip
insight` 解析失败 退化为 Markdown 渲染(报告区不空屏)
结果为空 允许分析(模型应回答"无数据"),入口文案提示"当前结果为空"
结果数 > topN ContextBar 明示「已按持续月数降序取前 100 对」,避免误以为全量
SSE 中断 落库为 status=INTERRUPTED 的 assistant 消息(沿用现有 partial 落库语义);前端保留已收内容并给「继续」提示
会话被并发打开 抢不到锁 / status=STREAMING → 409,提示「该分析正在进行中」,不静默排队
滚动加载失败 保留已有消息与滚动位置,列表底部给「加载失败,点击重试」,不回退 page、不重置列表
分页轻微重复 按消息 id 去重后再入列表(页码分页的已知取舍,见 §4.1.5)
DB 不可用 明确报错(不做静默降级到内存),避免出现「本次分析刷新就没了」的静默数据丢失

6. 分阶段实施

阶段 内容 可独立验收
P0 两张表 DDL + entity/Mapper + seq 原子分配 + 消息分页接口 + 后端会话与流式 + 动态 sysPrompt + 前端抽屉(主按钮入口)+ 纯 Markdown 报告 ✅ 能一键出解读,刷新后消息还在
P1 insight围栏 +InsightBlock` 卡片化(线索卡 / 下一步思路 / 跳转按钮) ✅ 结构化线索可用
P2 历史分析列表(抽屉「历史」下拉)+ InsightMessageList 滚动加载 + trace 按需拉取 + 后端 GET /clues(仅预留,不接页面) ✅ 结果可回溯、长会话可翻
后续(不在本次范围) 对接「线索推送」页 + 线索卡「记入线索」+ 行级「AI 解读」入口(scope=PAIR) —

P0 的存储层是纯基础设施,可以脱离 AI 单独验证:用单元测试覆盖 「seq 并发分配不重号 / 分页排序稳定不重不漏 / 列表查询不含大字段 / case_id 归属校验拦越权 / 级联删除生效」即可。


7. 提交前检查清单

  • 后端 mvn -pl ai-server -am clean compile 通过
  • 前端 pnpm type:check + pnpm lint:eslint 无错 (注:仓库 person / trans 模块有大量既有类型错误,与本功能无关; 本次改动文件单独跑 ESLint / Stylelint / 类型检查均为 0 错)
  • 新增两张表:agent_insight_session / agent_insight_message,DDL 落 sql/; 确认未改动 agent_chat_session / agent_message / 元数据三表 (InsightStorageContractTest#insightTablesAreIndependentFromChatTables 断言 DDL 不引用通用对话表)
  • uk_aim_session_seq (session_id, seq) 唯一约束到位;seq 由 UPDATE ... RETURNING last_seq 原子分配 (两条都有测试;另有「全 insight 包不出现 max(seq)」的扫描断言)
  • 没有任何 SQL 只按 session_id 查(消息查询一律带 case_id),并有测试覆盖 (XML 逐条 SELECT 断言 + Mapper 签名反射断言「每个读方法必须有 @Param("caseId")」)
  • 列表/分页查询显式列清单,无 SELECT *,不取 tool_events / output_json
  • 消息分页:page / limit 白名单校验(page ≥ 1、limit ≤ messagePageSizeMax), 排序 ORDER BY seq DESC 服务端写死、忽略 orderKey/sort
  • 会话锁 + status 状态位在 finally 复位;409 语义正确
  • 「UPDATE last_seq + INSERT message + UPDATE message_count」在同一事务内 (用 TransactionTemplate 而非 @Transactional,因调用发生在 Reactor 线程)
  • 不污染现有 .agentscope(框架工作区),不复用 AgentService 的 agentPool 与 appendRenderPrompt
  • 新 Agent 提示词契约与前端 scanner.ts / BlockRenderer 同步(改契约必须两头改)
  • sys_prompt / context 不通过任何接口原样下发(context 可下发;sys_prompt 不下发)
  • 抽屉关闭 / 切换会话时 AbortController 与滚动监听已清理(防泄漏)
  • 无密钥 / 真实库密码 / 内网地址硬编码
  • 新图标已进 uno.config.ts safelist
  • 中文注释齐全,无调试残留

8. 已确认的设计决策

决策点 结论
入口形态 只做面板级主按钮。表格操作列不动;行级「AI 解读」本期不做,scope=PAIR 预留
线索输出形态 结构化围栏 + 卡片。P0 先出纯 Markdown 报告验收,P1 上 insight` 围栏与线索卡
线索沉淀 预留接口,暂不接页面。后端把结构化产物写进消息行 output_json + 提供 GET /insight/continuous/clues;「记入线索」与「线索推送」页对接留到后续需求
后端落位 module/agent/insight/ 子包(不新建 module/insight)
会话存储 入库,独立两张表 agent_insight_session / agent_insight_message(不复用 agent_chat_session / agent_message)。隔离三层:独立表 → case_id 强制过滤 → session_id + case_id 双条件;详见 §4.1
消息滚动加载 普通分页接口(?page=&limit=,沿用 Query 约定 + MP Page),ORDER BY seq DESC 服务端写死;前端上滑到顶 page+1 + unshift + 滚动补偿。不用游标参数
面板形态 右侧抽屉 640px(可拖拽 480–1000)+ 右上「全屏」复用同组件
模型 默认 LLM 模型(getDefaultModelId()),maxIters=6

9. 风险与注意点

  1. 动态提示词必须换池。AgentService#agentPool 的 key 不含 sysPrompt 维度,一旦图省事复用现有池, 动态提示词会在不同分析会话之间串台 —— 这是本功能最容易踩的坑。
  2. 提示词契约与前端渲染必须同步改。改 insight结构时,scanner.ts/BlockRenderer.vue/ InsightBlock.vue` 要一起动,否则结构化块静默失效(退化成 Markdown,不报错,很难发现)。
  3. token 成本。结果快照 TOP 100 行按 6 列估算约 3–5k token,加人设与口径说明,单次首轮约 6–8k 输入。 TOP N 建议做成可配置(配置项或抽屉里的轻量开关),别硬编码。
  4. 模型稳定性。围栏 JSON 输出依赖模型遵循能力;提示词里要给出完整 schema 示例, 并明确「必须唯一一个围栏、必须合法 JSON」,否则解析失败率会偏高。
  5. 研判底线。无有效线索时必须显式说明原因 —— 这条要写死在提示词里,否则模型会为凑数编造线索, 在纪检/经侦场景下是严重问题。
  6. ★ 排序列的选择是本设计最容易做错的一处。id 是随机 UUID 派生(非单调)、create_at 会同毫秒撞车, 所以必须 ORDER BY seq。三条要写成测试:
    • UNIQUE(session_id, seq) 约束在 DDL 里真实存在(不能只写在文档里);
    • seq 走 UPDATE ... RETURNING last_seq 原子分配,不许用 SELECT max(seq)+1(READ COMMITTED 下会重号);
    • 分页排序稳定:同一会话连续翻页不重不漏(ORDER BY seq DESC + page/pageSize)。
  7. 分页接口的取舍要认账。用 page / pageSize 比游标简单、能直接复用 MP Page, 但消息持续追加时会出现「同一条消息出现在两页」的轻微重复。本项目单会话几十条、追加只发生在用户自己发问时, 实际影响可忽略;前端按 id 去重兜底,不要为此把分页复杂化。
  8. 「隔离」在 DB 方案下靠「强制 where」,它比文件方案的路径校验安全(写错了静态能查出来), 但仍有漏洞可钻:只要有人新写一个 selectBySessionId(sessionId) 就破了。对策是把 case_id 做成 Mapper 方法的必填签名参数,并加一条测试断言「不存在只按 session_id 查消息的调用路径」。
  9. 大字段是列表接口的隐形地雷。tool_events 单条可达数百 KB(实测 297KB),一旦列表接口 SELECT * 或误带 output_json,首屏就会变成几 MB 响应。列表查询禁用 SELECT *,output_json 只在首轮消息详情/线索聚合里取。
  10. 流式落库是「流末一次写」,不是 token 级 UPDATE。要确认产品上接受「流中断时消息状态为 INTERRUPTED 但正文已保存」,与现有 /chat 行为一致;不要为了「实时存」引入写放大。
  11. 案件删除的语义要先确认。ON DELETE CASCADE 只在物理删除会话行时生效; 若案件删除是软删(status=DELETED),需要在案件删除事件里显式清理本表,或在查询侧过滤 —— 这是最容易漏的一环。

10. 实施状态(P0 + P1 + P2 已落地)

10.1 后端新增(23 个 Java + 2 XML + 1 提示词 + 1 DDL)

落位 ai-server/src/main/java/com/zsjz/ai/module/agent/insight/:

目录 文件
entity/ AgentInsightSession、AgentInsightMessage
mapper/ AgentInsightSessionMapper、AgentInsightMessageMapper
config/ InsightProperties(zsjz.insight.*)
context/ InsightContext、InsightContextBuilder
prompt/ InsightPromptBuilder
parser/ InsightPayloadParser
agent/ InsightAgentFactory
dto/ InsightCreateDTO、InsightStreamDTO、InsightMessageQuery、InsightRowDTO、InsightFilterDTO
vo/ InsightSessionVO、InsightMessageVO、InsightMessageTraceVO、InsightContextVO、InsightClueVO
service/ ContinuousInsightService + impl/ContinuousInsightServiceImpl
controller/ ContinuousInsightController

资源:resources/mappers/insight/{AgentInsightSessionMapper,AgentInsightMessageMapper}.xml、 resources/prompts/insight/CALL_CONTINUOUS_INSIGHT.md、sql/insight_agent_tables.sql。

改动既有文件 2 处:

  • module/agent/tools/AgentToolRegistry.java —— registerBusinessTools(Toolkit) 保留原签名,新增 registerBusinessTools(Toolkit, List<String> activeGroups) 重载;createGroup(...) 增 activeGroups 形参, 由 activeGroups.contains(groupName) 决定工具组是否默认激活(向后兼容,其它调用方零改动)。
  • resources/application.yaml —— 新增 zsjz.insight.* 配置节。

10.2 前端新增(13 个文件)

目录 文件
ai/api/ sse.ts(从 chatApi.ts 抽出,chatApi.ts 改为再导出)
ai/components/blocks/ InsightBlock.vue(通用只读渲染块)
call/api/types/ insight.ts
call/api/ insightApi.ts
call/views/continuous/hooks/ useInsightContext.ts、useContinuousInsight.ts
call/views/continuous/components/ ContinuousInsightDrawer.vue、InsightReport.vue、InsightClueCard.vue、InsightNextSteps.vue、InsightContextBar.vue、InsightMessageList.vue、InsightHistoryPopover.vue

改动既有文件:continuous/index.vue(panel-header 加 AI 研判 主按钮 + 挂载抽屉 + 条件快照)、 ai/utils/constants.ts(BlockKind / FENCE_BLOCK_MAP 加 insight)、ai/api/types.ts(Insight* 类型 + SseInsightData + onInsight)、ai/utils/scanner.ts(isInsightPayload + 围栏解析分支)、 ai/components/BlockRenderer.vue、ai/utils/parseMessageContent.ts、uno.config.ts(safelist 补 11 个图标)。

10.3 实现与文档的差异(3 处,均为落地细节)

  1. @Transactional → TransactionTemplate。persistMessage() 在 Reactor 线程调用, 注解式事务不生效,改为 TransactionTemplate.executeWithoutResult() 显式包住 「nextSeq + insertMessage + bumpAfterMessage」三步。
  2. 越权写库防护下沉到 SQL。insertMessage 写成 INSERT INTO agent_insight_message (...) SELECT ... FROM agent_insight_session s WHERE s.id = #{m.sessionId} AND s.case_id = #{m.caseId}, 归属不匹配时影响行数为 0,Service 侧据此抛 403 —— 比「先查再写」少一次往返且不可绕过。
  3. 前端筛选条件用「已生效快照」。index.vue 新增 insightFilters,只在 fetchFirstLevelTable() 成功后刷新,避免用户改了表单但没点查询时抽屉误报「条件已变更」。

10.4 数据库落地情况(已对齐)

执行库:jdbc:postgresql://192.168.0.109:5432/zsjz-ai(PostgreSQL 18.4)。

发现的问题:agent_insight_session / agent_insight_message 两张表的列已经存在 (18 + 16 列,与 DDL 完全一致,含 NOT NULL 与主键),但约束与索引一个都没有 —— 说明这两张表当初是用某个「按实体建表」的工具建的,insight_agent_tables.sql 从未真正执行过。

根因:uk_aim_session_seq / fk_aim_session 写在 CREATE TABLE 语句内部, 而 CREATE TABLE IF NOT EXISTS 对「表已存在」的情况整句跳过,连内部约束一起跳过; 只有语句外的 CREATE INDEX IF NOT EXISTS 才会被补建。

已修复:新增 sql/insight_agent_tables_align.sql(幂等补丁,23 条语句全部执行成功),补齐:

对象 状态
uk_aim_session_seq UNIQUE (session_id, seq) ✅ 已建
fk_aim_session ... ON DELETE CASCADE ✅ 已建
idx_ais_case_list / idx_ais_ctx_hash / idx_aim_output ✅ 已建
表 / 列 COMMENT ✅ 已补

执行前做了只读前置检查(孤儿消息 0、重复 (session_id, seq) 0、空 case_id 0), 加约束前确认现有数据不冲突;脚本内置 SET lock_timeout = '5s' 避免在后端有长事务时无限等待。

库里已有 2 条会话 / 1 条消息(功能已跑通),补丁未触碰任何数据行。

10.5 未做 / 待办

  • table_info / table_field 元数据未登记:经核对,这两张元数据表登记的是案情/公安数据库表 (cert_info / entry_rec / vehicle_info …,classify='公安数据库'),供 Agent 的 SQL schema 检索使用; 本功能两张表建在 master 平台库、Agent 不检索它们,故不属于 AI_AGENT.md §5「新增表结构同步补元数据」的适用范围。
  • 需要真实数据库的用例未覆盖:seq 并发不重号、级联删除生效这两条依赖 PG 行级锁与真实外键, 本项目没有 Testcontainers / H2 基础设施,无法在内存库里验证 —— 已用静态断言覆盖其前置条件(见 §10.6)。
  • GET /insight/continuous/clues 已按设计仅预留,未接任何页面。
  • scope=PAIR(行级「AI 解读」)与「记入线索」 不在本次范围。

10.6 测试(57 个用例,零外部依赖)

三个测试类,全部不连数据库、不起 Spring 容器(mvn -pl ai-server test -Dtest='Insight*Test'):

测试类 用例数 守什么
InsightStorageContractTest 21 存储层不变量:DDL 约束、列清单、归属条件、Mapper 签名、VO 字段白名单
InsightPayloadParserTest 18 `insight 围栏解析:什么必须通过、什么必须拦住
InsightPromptAssemblyTest 18 动态提示词:四段结构、行数截断、口径说明、快照序列化与哈希

落地手段(对应 §6「存储层可脱离 AI 单独验证」与 §9 风险 6/8「约束要写成测试」):

  • 静态文本断言:读 DDL 与 Mapper XML 原文,断言 UNIQUE (session_id, seq)、ON DELETE CASCADE、 部分索引 idx_aim_output、listColumns 排除大字段、ORDER BY seq DESC 写死、INSERT ... SELECT ... WHERE 归属校验、nextSeq 用 RETURNING 且全包无 max(seq)。 断言前统一剔除注释(-- / <!-- --> / //)—— 注释里会写「不复用 agent_chat_session」「禁止 max(seq)+1」, 不剔除会造成误报与漏报。
  • 反射签名断言:AgentInsightMessageMapper 每个读方法必须有 @Param("caseId") 形参 —— 这是堵「有人新写一个 selectBySessionId(sessionId) 就破了隔离」(§9 风险 8)最直接的手段。
  • VO 字段白名单:所有 VO 不含 sysPrompt;toolEvents 只在 trace VO;列表 VO 不含 toolEvents / outputJson。

10.7 实现期间修正的三处缺陷

① 死字段:InsightMessageVO.outputJson

InsightMessageVO 曾保留 outputJson 字段并注释「体积可控且首屏卡片要用,故保留」, 但列表 SQL 的 listColumns 排除了 output_json、trace VO 也不含该字段 —— 它永远是 null,是死字段且注释与实现矛盾。 按 §7 检查清单「列表不取 output_json」删除了该字段(后端 VO + convertMessage + 前端类型 + 前端两处 parseOutputJson 调用), 线索卡改由唯一路径渲染:content 里的 `insight 围栏 → scanner.ts 解析 → pickInsightPayload(blocks)。 这条路径本来就是必需的(报告正文要逐块解析才能渲染),结构化产物是搭便车,不额外付成本。

② 前端卸载泄漏:拖拽监听与 SSE 流

ContinuousInsightDrawer 原来的 onBeforeUnmount 只把 resizing 置 false,没有摘掉 window 上的 mousemove / mouseup —— onMove / onUp 是 startResize 内的局部函数,外部拿不到引用, 拖拽中途卸载(路由切走)会永久留下两个监听,之后鼠标移动还会去改已销毁组件的 width。

同一处还有个更贵的问题:watch(() => props.open) 只在 open 变 false 时调 stop(), 而父组件直接销毁不会触发这个 watch —— SSE 流会在后台继续跑到服务端结束,白烧 token。

修法:startResize 把解绑逻辑收进 detachResize 闭包,onBeforeUnmount 统一调用它并补 stop()。

③ 入口按钮「字看不到」(ghost + disabled 的组合陷阱)

按钮原本写成 <Button type="primary" ghost :disabled="!canInsight">。 ghost 是「透明底 + 主色描边文字」,而初始状态(未查询)必然 disabled —— antd 对 disabled 按钮给 color: rgba(0,0,0,.25)(≈#bfbfbf), 于是「透明底 + 极淡字」在白面板上等于看不见,用户根本发现不了这个入口。

修法(§2.1 已同步):

  1. 去掉 ghost,改用项目通用的实心 type="primary"(与同页「查询」按钮一致);
  2. 去掉 :disabled,改为 @click="openInsight" —— 条件不满足时用 createMessage.warning(reason) 说明原因。入口灰着等于藏起来,「始终可见可点 + 说明原因」的发现成本更低;
  3. 为此新增的自定义配色样式一并删除,只留 display: inline-flex; align-items: center; flex-shrink: 0;
  4. canInsight 随之删除;insightDisabledReason 的文案补全,供点击提示使用。 后来连 Tooltip 包装也一并去掉:按钮直接挂在 .panel-header 下,少一层 cloneVNode 包装, 最省事也最不容易出问题。

通用教训:ghost 只适合次级动作,入口 / 主按钮不要用 —— 它依赖主题 token, 且 disabled 态几乎没有对比度。项目按钮语言:实心 primary = 主按钮,默认(白底描边)= 次要。