范围:
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 | 解读摘要 | 这批持续联系关系的整体特征:覆盖几人、最长/最短关系、月均密度分布、异常点在哪 |
| 2 | 价值线索(Clues) | 分级(高/中/低)的可落地线索,每条带具体数字证据 + 可疑理由 + 建议核验动作 |
| 3 | 下一步思路(Next Steps) | 可执行动作清单,每条指明"在哪做"(跳转到对应页面/工具) |
| 4 | 追问下钻 | 对任一条线索继续追问,走普通对话流(复用现有富渲染块) |
AI_AGENT.md 领域术语)| 线索类型 | 触发特征(从持续联系数据可判) |
|---|---|
| 通联异常 | 深夜/凌晨占比显著偏高;月数很长但月内次数极低("养号式"维系);单向高频 |
| 攻守同盟 | 星型结构:多个对象 → 同一对方号码,或同一对方 ↔ 多个本方 |
| 身份不明对手 | otherName = 未知对手 / otherPhone = 未知号码,但持续多月高频联系 |
| 反常断联 | 连续多月后在某月骤停(可能对应被采取强制措施、外逃、换号) |
| 敏感日期命中 | 特殊日期仍保持联系(需交叉验证,激活 call 组下钻) |
| 白手套 / 时空伴随 | 需跨域佐证(trans / track 组),由 Agent 自行按需激活工具组 |
最后两类不能只凭本表推断,Agent 必须先调工具取数再下结论——这一点写进提示词硬约束。
| 编号 | 入口位置 | 语义 | 携带上下文 |
|---|---|---|---|
| E1 主入口 | 「持续联系对象汇总」卡片标题栏右侧 —— AI 研判 主按钮(描边高亮 + i-mdi:auto-fix 图标) |
分析当前整个查询结果集 | 完整查询条件 + 结果快照(按持续月数降序取 TOP N,默认 100) |
| E2 空态引导 | 未查询时的 EmptyState 下方一行小字 + 按钮 |
引导先查询 | 无 |
buildTableActionColumn,只有「查看详情」,列宽不动)。!hasSearched || !tableData.length,tooltip 提示原因("请先查询" / "当前无数据")。行级入口(单条关系对「AI 解读」)本期不做。 表结构保留
scope字段(BATCH/ 预留PAIR),后续要加行级入口时只需补一个按钮, 后端 Service 与存储无需改动。
| 方案 | 形态 | 优点 | 缺点 | 结论 |
|---|---|---|---|---|
| A 右侧抽屉 ✅ | 从右滑出,宽 640px(可拖拽 480–1000) | 表格保留在左侧,可边看边问;不丢上下文;实现成本最低 | 横向空间有限,长报告需滚动 | 推荐 |
| B 内嵌右分栏 | 表格右侧常驻 380–420px | 零点击即见 | 本页筛选条 + 6 列表格已很满,1680 宽以下横向挤压;AI 区大部分时间是空的 | 否 |
| C 独立全屏页 | 跳 /call/continuous/insight |
空间大,适合长报告 | 割裂,丢失"边看边问";且与 AI 数据分析页的会话混在一起(与"独立存储"诉求相悖) | 否(保留为升级路径) |
| D 底部气泡/悬浮球 | 悬浮面板 | 存在感低 | 不适合需要长阅读与翻页表格的报告 | 否 |
采纳 A,并在抽屉右上角留「全屏」按钮:全屏复用同一组件铺满容器(CSS 切换),不新建页面。
┌─ ① Header ──────────────────────────────────────────────┐
│ AI 研判 · 持续联系 [↻重新分析][▤历史][⛶全屏][✕] │
├─ ② ContextBar(固定,不可滚)────────────────────────────┤
│ ◈ 对象 3 人 ◈ 月数 ≥2 ◈ 2024-01-01~2024-12-31 ◈ 128 对 │
│ 已按持续月数降序取前 100 对进行研判 │
├─ ③ Report / ④ Chat(滚动区)────────────────────────────┤
│ [结论摘要卡] │
│ ┌ 线索卡 #1 ●高价值 通联异常 ─────────────────────────┐ │
│ │ 标题 / 证据 / 可疑点 / 建议核验 │ │
│ │ [查看话单][地图][深挖][记入线索] │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌ 线索卡 #2 … ┐ │
│ [下一步思路 · 编号清单 + 跳转按钮] │
│ [▸ 分析过程(默认折叠,含工具调用时间线)] │
│ ─── 以下为追问对话(复用现有消息组件)─── │
├─ ⑤ Composer(固定底)──────────────────────────────────┤
│ [预设追问 chip ×3] ┌──────────────────────┐ [发送] │
│ │ 追问… │ │
└─────────────────────────────────────────────────────────┘
关键交互
POST /insight/continuous/sessions → 立刻 POST /insight/continuous/stream(首轮 message 为空,服务端注入固定首轮指令)。insight` 围栏输出结构化 JSON → 前端渲染成卡片;模型若未吐围栏则自动降级为 Markdown 报告(不空屏)。maskClosable=false:防止误点遮罩丢流;关闭时若有流在跑,先中止(复用现有 AbortController 语义)。这是"线索 + 下一步思路"能被渲染成可用 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": ["本表为汇总口径,未含通话时长与基站信息;结论需下钻话单核验"]
}
落地要点
AgentService#appendRenderPrompt 的通用契约)。ai/utils/constants.ts:BlockKind 加 'insight';FENCE_BLOCK_MAP 加 insight: 'insight'ai/utils/scanner.ts:围栏识别自动覆盖(按 map 查表)ai/components/BlockRenderer.vue:加一个分支 → 新组件 blocks/InsightBlock.vueclues 是数组、每条有 title/evidence);不合法或未解析 → 保留原始 Markdown 文本渲染,绝不白屏。insight(结构化对象):前端不必等 token 拼完就能先渲染卡片骨架,体验更稳。本节是本设计的重点。会话与消息入库,但不复用
agent_chat_session/agent_message, 另建两张独立表agent_insight_session/agent_insight_message。 「隔离」由独立表 +case_id归属 + 外键级联 + 强制 where 建立; 「滚动加载」由普通分页接口 + 会话内单调seq稳定排序支撑。
本项目已移除用户体系(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):
session_id 查消息的 SQL —— 消息列表/详情一律
WHERE session_id = ? AND case_id = ?。因此消息表冗余一列 case_id,让这条约束在 SQL 层面就能写出来。agent_insight_session.case_id 与入参比对,不一致直接 403,再落库。biz_type 恒为常量 —— 首期恒 'CALL_CONTINUOUS',作为「同表将来扩其它研判场景」的预留维度;
所有查询默认带上它,避免将来多场景共表时串数据。为什么消息表冗余
case_id(违背范式):只靠session_idJOIN 会话表也能过滤case_id, 但那让每个新写的查询都有「忘记带 case_id」的机会。冗余一列 + 联合索引,代价极小, 换来「越权查询在 SQL 层面就写不出来」的强约束 —— 这正是文件方案(§4.1.8 对比)拿不到的保护网。
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%;部分索引体积小、命中准 |
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单独扛排序的原因。
| 场景 | 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)冗余索引。
用普通分页(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,避免带着上个会话的页码请求。id 去重兜底即可。| 场景 | 机制 |
|---|---|
| 同一会话并发两条流(两个页签 / 重复点「重新分析」) | 双保险:先 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 级联删除 |
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 的写法。
| 文件方案得自己补的 | 入库后 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或在案件删除事件里显式清理。实现时先确认案件删除的实际语义。
决策:放在 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 已承担这个职责,再加一层只是空转发。
这是本功能的技术核心,也是与现有 Agent 最大的差异点。
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},按持续月数降序)
| 本方 | 对方 | 对方号码 | 连续月数 | 通话次数 | 起止月份 |
|------|------|----------|----------|----------|----------|
| … | … | … | … | … | … |
| 问题 | 决策 | 原因 |
|---|---|---|
| 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 | 语义干净:一次分析 = 一个会话 = 一份上下文 |
| 项 | 取值 | 说明 |
|---|---|---|
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 决定。现有调用方零改动。
| 方法 | 路径 | 说明 |
|---|---|---|
| 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/** 保持同一风格,文档化在此。)
prompts/insight/CALL_CONTINUOUS_INSIGHT.md)人格:资深通联行为研判员(不是通用助手,不寒暄、不复述问题)。
硬性输出契约:
insight` 围栏 JSON(结构见 §3),再输出一段 Markdown 供人阅读。high/medium/low。138****1111 形式脱敏展示。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 共用(纯重构、零行为变化)。
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 / 动态拼类名不会被扫描)。
useContinuousInsight)idle → creating(建会话)→ streaming(首轮/追问)→ done
↘ error(模型未配置 / 网络 / 超限 / 409 进行中)
sessionId、messages(按 seq 升序)、page / pages / total、insightPayload(结构化产物)、toolSteps、thinking、streaming、abortControllerloadPage(page) 拉第 N 页(ORDER BY seq DESC)→ reverse() 后 unshift + 滚动补偿;首屏与切会话都走 loadPage(1)parseMessageBlocksIncremental / parseToolEvents / parseThinkingFromMetadata
(节流重扫 + 已完成块冻结,避免长回答 O(n²))sessionId;stop() = abortController.abort()| 场景 | 行为 |
|---|---|
| 无默认模型 | 抽屉内提示「请先在 AI 模型管理中设置默认模型」+ 跳转按钮,不发请求 |
未打开案件(无 caseId) |
入口禁用 + tooltip |
insight` 解析失败 |
退化为 Markdown 渲染(报告区不空屏) |
| 结果为空 | 允许分析(模型应回答"无数据"),入口文案提示"当前结果为空" |
| 结果数 > topN | ContextBar 明示「已按持续月数降序取前 100 对」,避免误以为全量 |
| SSE 中断 | 落库为 status=INTERRUPTED 的 assistant 消息(沿用现有 partial 落库语义);前端保留已收内容并给「继续」提示 |
| 会话被并发打开 | 抢不到锁 / status=STREAMING → 409,提示「该分析正在进行中」,不静默排队 |
| 滚动加载失败 | 保留已有消息与滚动位置,列表底部给「加载失败,点击重试」,不回退 page、不重置列表 |
| 分页轻微重复 | 按消息 id 去重后再入列表(页码分页的已知取舍,见 §4.1.5) |
| DB 不可用 | 明确报错(不做静默降级到内存),避免出现「本次分析刷新就没了」的静默数据丢失 |
| 阶段 | 内容 | 可独立验收 |
|---|---|---|
| P0 | 两张表 DDL + entity/Mapper + seq 原子分配 + 消息分页接口 + 后端会话与流式 + 动态 sysPrompt + 前端抽屉(主按钮入口)+ 纯 Markdown 报告 |
✅ 能一键出解读,刷新后消息还在 |
| P1 | insight围栏 +InsightBlock` 卡片化(线索卡 / 下一步思路 / 跳转按钮) |
✅ 结构化线索可用 |
| P2 | 历史分析列表(抽屉「历史」下拉)+ InsightMessageList 滚动加载 + trace 按需拉取 + 后端 GET /clues(仅预留,不接页面) |
✅ 结果可回溯、长会话可翻 |
| 后续(不在本次范围) | 对接「线索推送」页 + 线索卡「记入线索」+ 行级「AI 解读」入口(scope=PAIR) |
— |
P0 的存储层是纯基础设施,可以脱离 AI 单独验证:用单元测试覆盖 「
seq并发分配不重号 / 分页排序稳定不重不漏 / 列表查询不含大字段 /case_id归属校验拦越权 / 级联删除生效」即可。
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)」的扫描断言)session_id 查(消息查询一律带 case_id),并有测试覆盖
(XML 逐条 SELECT 断言 + Mapper 签名反射断言「每个读方法必须有 @Param("caseId")」)SELECT *,不取 tool_events / output_jsonpage / limit 白名单校验(page ≥ 1、limit ≤ messagePageSizeMax),
排序 ORDER BY seq DESC 服务端写死、忽略 orderKey/sortstatus 状态位在 finally 复位;409 语义正确UPDATE last_seq + INSERT message + UPDATE message_count」在同一事务内
(用 TransactionTemplate 而非 @Transactional,因调用发生在 Reactor 线程).agentscope(框架工作区),不复用 AgentService 的 agentPool 与 appendRenderPromptscanner.ts / BlockRenderer 同步(改契约必须两头改)sys_prompt / context 不通过任何接口原样下发(context 可下发;sys_prompt 不下发)AbortController 与滚动监听已清理(防泄漏)uno.config.ts safelist| 决策点 | 结论 |
|---|---|
| 入口形态 | 只做面板级主按钮。表格操作列不动;行级「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 |
AgentService#agentPool 的 key 不含 sysPrompt 维度,一旦图省事复用现有池,
动态提示词会在不同分析会话之间串台 —— 这是本功能最容易踩的坑。insight结构时,scanner.ts/BlockRenderer.vue/
InsightBlock.vue` 要一起动,否则结构化块静默失效(退化成 Markdown,不报错,很难发现)。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)。page / pageSize 比游标简单、能直接复用 MP Page,
但消息持续追加时会出现「同一条消息出现在两页」的轻微重复。本项目单会话几十条、追加只发生在用户自己发问时,
实际影响可忽略;前端按 id 去重兜底,不要为此把分页复杂化。selectBySessionId(sessionId) 就破了。对策是把 case_id
做成 Mapper 方法的必填签名参数,并加一条测试断言「不存在只按 session_id 查消息的调用路径」。tool_events 单条可达数百 KB(实测 297KB),一旦列表接口 SELECT *
或误带 output_json,首屏就会变成几 MB 响应。列表查询禁用 SELECT *,output_json 只在首轮消息详情/线索聚合里取。INTERRUPTED 但正文已保存」,与现有 /chat 行为一致;不要为了「实时存」引入写放大。ON DELETE CASCADE 只在物理删除会话行时生效;
若案件删除是软删(status=DELETED),需要在案件删除事件里显式清理本表,或在查询侧过滤 —— 这是最容易漏的一环。落位 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.* 配置节。| 目录 | 文件 |
|---|---|
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 个图标)。
@Transactional → TransactionTemplate。persistMessage() 在 Reactor 线程调用,
注解式事务不生效,改为 TransactionTemplate.executeWithoutResult() 显式包住
「nextSeq + insertMessage + bumpAfterMessage」三步。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 —— 比「先查再写」少一次往返且不可绕过。index.vue 新增 insightFilters,只在 fetchFirstLevelTable()
成功后刷新,避免用户改了表单但没点查询时抽屉误报「条件已变更」。执行库: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 条消息(功能已跑通),补丁未触碰任何数据行。
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 解读」)与「记入线索」 不在本次范围。三个测试类,全部不连数据库、不起 Spring 容器(mvn -pl ai-server test -Dtest='Insight*Test'):
| 测试类 | 用例数 | 守什么 |
|---|---|---|
InsightStorageContractTest |
21 | 存储层不变量:DDL 约束、列清单、归属条件、Mapper 签名、VO 字段白名单 |
InsightPayloadParserTest |
18 | `insight 围栏解析:什么必须通过、什么必须拦住 |
InsightPromptAssemblyTest |
18 | 动态提示词:四段结构、行数截断、口径说明、快照序列化与哈希 |
落地手段(对应 §6「存储层可脱离 AI 单独验证」与 §9 风险 6/8「约束要写成测试」):
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)最直接的手段。sysPrompt;toolEvents 只在 trace VO;列表 VO 不含 toolEvents / outputJson。① 死字段: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 已同步):
ghost,改用项目通用的实心 type="primary"(与同页「查询」按钮一致);:disabled,改为 @click="openInsight" —— 条件不满足时用 createMessage.warning(reason)
说明原因。入口灰着等于藏起来,「始终可见可点 + 说明原因」的发现成本更低;display: inline-flex; align-items: center; flex-shrink: 0;canInsight 随之删除;insightDisabledReason 的文案补全,供点击提示使用。
后来连 Tooltip 包装也一并去掉:按钮直接挂在 .panel-header 下,少一层 cloneVNode 包装,
最省事也最不容易出问题。通用教训:ghost 只适合次级动作,入口 / 主按钮不要用 —— 它依赖主题 token,
且 disabled 态几乎没有对比度。项目按钮语言:实心 primary = 主按钮,默认(白底描边)= 次要。