# 持续联系 · AI 研判功能 设计文档 > 范围:`ai-frontend/src/call/views/continuous/`(持续联系页)新增「AI 研判」能力 > 目标:把当前查询结果交给专用 Agent 解读,产出**有价值的线索** + **下一步思路** > 状态:设计待确认,未开始编码 > > 关键决策速览:入口只做面板级主按钮(§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**(而不是一坨文字)的前提。 ```jsonc { "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` 四个字段。 ```sql -- ============================================================ -- 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` 的原子分配**(并发写同一会话不会重号,也不需要应用侧加锁): ```sql -- 与 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` 的形状,不另造壳): ```jsonc { "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` 拦一道;再 `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 配置项 ```yaml 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 规定 `` 为固定取值集合(`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,不另造 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 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` 包装), 理由:前端 `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 研判` 主按钮(`:disabled="!hasSearched \|\| !tableData.length"`) | | 模板末尾 | 新增 `` | | `