cc 2 週間 前
親
コミット
6e08204273
2 ファイル変更、794 行追加、0 行削除
  1. 90 0
      .workbuddy/memory/2026-09-17.md
  2. 704 0
      continuous-insight-plan.md

+ 90 - 0
.workbuddy/memory/2026-09-17.md

@@ -574,4 +574,94 @@ PowerShell 前台直接输出在本环境常被吞,写文件再 Read 才可靠
 - 跑长命令用 `> 日志文件 2>&1` 落盘再读;在 bash 里用 `node -e` 过滤日志时,正则里的 `\.java:\[`
   会被 shell 转义搞坏(`Unterminated regexp literal`),别把复杂正则塞进命令行。
 
+---
+
+## 持续联系页「AI 研判」功能 —— 设计文档(待用户确认,未编码)
+
+产出:`continuous-insight-plan.md`(仓库根)+ 4 张布局草图(show_widget 内联)。
+
+### 关键设计决策(用户确认后才动手)
+- **入口**:① 面板标题栏「AI 研判」主按钮(分析整个查询结果集,TOP 100)② 操作列「AI 解读」
+  (单条关系对,操作列 120 → 176)。抽屉打开即自动发起首轮分析,不用敲字。
+- **面板形态**:右侧抽屉 640px(可拖拽 480–1000)+ 右上「全屏」复用同组件。不新建页面。
+- **结构化输出**:新增 ` ```insight ` 围栏 → 前端新增 `InsightBlock.vue` 渲染线索卡
+  (等级/类型/证据/可疑点/动作按钮)+ 下一步思路清单。扩展点只有三处:
+  `utils/constants.ts`(BlockKind + FENCE_BLOCK_MAP)、`scanner.ts`(查表自动覆盖)、
+  `BlockRenderer.vue`(加分支)。解析失败退化为 Markdown,不空屏。
+- **存储独立**:新表 `agent_insight_session` / `agent_insight_message`(含 `context` jsonb、
+  `output_json` jsonb 便于线索跨会话聚合),**完全不复用** `agent_chat_session` / `agent_message`。
+- **★ 动态系统提示词**:`sysPrompt = 静态人设(prompts/insight/CALL_CONTINUOUS_INSIGHT.md)
+  ⊕ 动态上下文(案件+查询条件+数据口径说明+结果数据表)⊕ insight 围栏契约`。
+  创建会话时拼好并落库,**会话内不变**;追问不重新拼装。
+- **★ 必须新开 Agent 实例池**:现有 `AgentService#agentPool` 的 key 是
+  `w{workspaceId}-a{agentRowId}-m{modelId}`,**没有 sysPrompt 维度** —— 复用会让动态提示词在
+  不同会话间串台。新池 key = `insight-s{sessionId}`,LRU 64 / 空闲 30min 回收。
+- **Agent 参数**:默认 LLM 模型;maxIters=6;不挂 Intent/Followup 中间件(Followup 的"下一步建议"
+  与本功能自带的 nextSteps 重复);工具默认装备 `call` 组,其余组由模型自行 `reset_equipped_tools` 激活。
+- **需要的向后兼容改造**:`AgentToolRegistry#registerBusinessTools(Toolkit)` 增加重载
+  `(Toolkit, List<String> activeGroups)`(原方法委托 `DEFAULT_ACTIVE_GROUPS`),现有调用方零改动。
+- **代码落位**:`module/agent/insight/`(不新建 `module/insight`,因 `AI_AGENT.md §4` 规定
+  `<domain>` 固定取值集合,且强耦合 agent 子系统;"独立存储"由独立表满足)。
+- **接口**:`/insight/continuous/{sessions,stream,sessions/{id}/messages,clues}` +
+  `GET /insight/continuous/messages/{messageId}/trace`(按需拉工具明细/思考);
+  **所有读接口一律 `id + case_id` 双条件**。SSE 与 `/chat/stream` **完全同构**(仅新增 `insight` 一帧),
+  前端解析器 100% 复用;`/insight/**` 与 `/chat/**` 一致走**裸返回**
+  (有意偏离 AGENT.md §4 的 Result 约定,已文档化)。
+- **前端不复用 `ai/store/chatStream.ts`**(单例全局态,与 AI 分析页会互相搅乱)→ 新写
+  `useContinuousInsight.ts` 组合式 hook;但把 SSE 解析器从 `chatApi.ts` 抽到 `ai/api/sse.ts` 共用。
+- **提示词硬约束**:线索 3–6 条、每条必须有可核验数字证据、必须给下一步核验动作、
+  **无有效线索时必须显式说明原因禁止编造凑数**、禁止编造未提供数据、引用号码脱敏。
+
+### 用户已确认(2026-09-17)
+- **入口:只做面板级主按钮**,表格操作列不动;行级「AI 解读」本期不做(`scope=PAIR` 预留)。
+- **线索输出:结构化围栏 + 卡片**(P0 先出纯 Markdown 报告验收,P1 上 ` ```insight ` 与线索卡)。
+- **线索沉淀:预留接口不接页面**(后端落 `output_json` + `GET /insight/continuous/clues`;
+  「记入线索」与线索推送页对接留到后续需求)。
+- **后端落位:`module/agent/insight/` 子包**。
+- ★ **会话存储:入库,独立两张表(用户最终拍板)**。文档 §4.1 已整节重写为「数据库表设计」:
+  - 表:`agent_insight_session`(id/case_id/biz_type/scope/agent_row_id/model_id/title/context jsonb/
+    context_hash/sys_prompt/message_count/last_seq/total_tokens/last_message_at/pinned/status/create_at/update_at)
+    + `agent_insight_message`(id/session_id/**case_id 冗余**/**seq**/role/content/content_type/message_type/
+    metadata jsonb/tool_events jsonb/output_json jsonb/token_count/reply_to_message_id/starred/status/create_at)
+  - **三层隔离**:独立表(与 `agent_chat_session` 物理分离)→ `case_id` 强制 where(硬边界)→
+    `session_id + case_id` 双条件(消息表冗余 `case_id` 就是为了让这条能在 SQL 层面写出来)。
+  - **三条不变量(要写成测试)**:① 不存在只按 `session_id` 查消息的 SQL;② 写消息前先校验会话归属;
+    ③ `biz_type` 恒常量 `CALL_CONTINUOUS`。
+  - ★ **滚动加载用「普通分页接口」,不用游标参数**(用户 2026-09-17 明确要求):
+    `GET .../sessions/{id}/messages?caseId=&page=&limit=`,**参数名沿用项目 `Query` 约定**
+    (`common/base/Query.java`:`page` 默认 1、`limit` 默认 20),复用 **MyBatis-Plus `Page`**,
+    返回 `{records, total, size, current, pages}`;**排序 `ORDER BY seq DESC` 服务端写死**
+    (忽略 `orderKey`/`sort`),第 1 页 = 最新 N 条。前端上滑到顶 `page+1` → `records.reverse()` →
+    `unshift` + **滚动位置补偿**(记 scrollHeight 差值);切会话/首屏都 `loadPage(1)`;
+    **不做 5s 轮询补消息**(靠 SSE 增量)。
+  - ⚠️ **MP 分页插件注册的方言是 `DbType.DUCKDB`**(`common/config/MybatisPlusConfig.java:20`)。
+    PG 与 DuckDB 都是 `LIMIT ? OFFSET ?`,本表在 PG 上可分页;若报方言错就退回 XML 手写
+    `LIMIT/OFFSET` + 单独 `count(*)`。
+  - ★ **`seq` 仍然必须有**(列保留),因为它是唯一可靠的**排序列**:
+    实体虽标 `@TableId(ASSIGN_ID)`,但 `AgentChatServiceImpl` 显式 `setId(generateId())` 覆盖,
+    而 `generateId()` = `UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE` —— **随机非单调**;
+    `create_at` 同毫秒撞车。所以 `ORDER BY seq DESC`,`seq` 由
+    `UPDATE agent_insight_session SET last_seq = last_seq + 1 ... RETURNING last_seq`
+    (配合会话行级锁)**原子分配**,比 `SELECT max(seq)+1` 安全(READ COMMITTED 下会重号)。
+  - ⚠️ **页码分页的已知取舍(已写进文档 §4.1.5 / §9.7)**:消息持续追加时会出现「同一条消息出现在两页」
+    的轻微重复 → 前端按 `id` 去重兜底,不为此把分页复杂化。
+  - `page` / `limit` 要白名单校验(`page ≥ 1`、`limit ≤ 100`)。
+  - ★ **列表查询绝不 select `tool_events` / `output_json`**(单条 result 实测可达 297KB)→
+    **禁用 `SELECT *`**;点开「分析过程」走独立接口 `GET /insight/continuous/messages/{messageId}/trace`。
+  - 并发:应用内 `ConcurrentHashMap` 锁 + `UPDATE ... SET status='STREAMING' WHERE status='ACTIVE'`
+    双保险(affectedRows=0 → 409);`seq` 分配靠 DB 行锁;消息落库是**消息粒度不是 token 粒度**;
+    「UPDATE last_seq + INSERT + UPDATE message_count」同一事务。
+  - 索引:`idx_ais_case_list (case_id, biz_type, last_message_at DESC)`、`idx_ais_ctx_hash (case_id, context_hash)`、
+    `idx_aim_output (case_id, create_at DESC) WHERE output_json IS NOT NULL`;`uk_aim_session_seq` 已够
+    支持 `seq DESC` 排序翻页,**不需要**再加 `(session_id, seq DESC)`。
+  - DDL 落 `sql/`;代码落位去掉 `store/` 文件包,改为 `entity/ AgentInsightSession|AgentInsightMessage`
+    + `mapper/`(归属过滤收在方法签名里,如 `selectByIdAndCase`)。
+  - 字段风格对齐现有实体:`jsonb` 用 `JsonbTypeHandler`、`create_at/update_at` 用 `FieldFill.INSERT/INSERT_UPDATE`
+    的 `LocalDateTime`(DDL 用 `TIMESTAMP`,不是 `TIMESTAMPTZ`)。
+  - 钩子提醒:`ON DELETE CASCADE` 只在**物理删会话行**时生效;案件若是软删要另行处理。
+
+### 状态
+设计已定稿(`continuous-insight-plan.md` §8 记录了全部决策、§9 记录了 10 条风险),
+**用户尚未下达开工指令,未编码**。
+
 

+ 704 - 0
continuous-insight-plan.md

@@ -0,0 +1,704 @@
+# 持续联系 · 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<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 配置项
+
+```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 规定 `<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 研判` 主按钮(`:disabled="!hasSearched \|\| !tableData.length"`) |
+| 模板末尾 | 新增 `<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` 无错
+- [ ] **新增两张表**:`agent_insight_session` / `agent_insight_message`,DDL 落 `sql/`;
+      确认**未改动** `agent_chat_session` / `agent_message` / 元数据三表
+- [ ] **`uk_aim_session_seq (session_id, seq)` 唯一约束到位**;`seq` 由 `UPDATE ... RETURNING last_seq` 原子分配
+- [ ] **没有任何 SQL 只按 `session_id` 查**(消息查询一律带 `case_id`),并有测试覆盖
+- [ ] 列表/分页查询**显式列清单,无 `SELECT *`**,不取 `tool_events` / `output_json`
+- [ ] 消息分页:`page` / `limit` 白名单校验(`page ≥ 1`、`limit ≤ 100`),**排序 `ORDER BY seq DESC` 服务端写死、忽略 `orderKey`/`sort`**
+- [ ] 会话锁 + `status` 状态位在 `finally` 复位;409 语义正确
+- [ ] 「`UPDATE last_seq` + `INSERT message` + `UPDATE message_count`」在同一 `@Transactional` 内
+- [ ] 不污染现有 `.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` 查消息的调用路径」。
+8. **大字段是列表接口的隐形地雷**。`tool_events` 单条可达数百 KB(实测 297KB),一旦列表接口 `SELECT *`
+   或误带 `output_json`,首屏就会变成几 MB 响应。**列表查询禁用 `SELECT *`**,`output_json` 只在首轮消息详情/线索聚合里取。
+9. **流式落库是「流末一次写」**,不是 token 级 UPDATE。要确认产品上接受「流中断时消息状态为
+   `INTERRUPTED` 但正文已保存」,与现有 `/chat` 行为一致;不要为了「实时存」引入写放大。
+10. **案件删除的语义要先确认**。`ON DELETE CASCADE` 只在**物理删除**会话行时生效;
+    若案件删除是软删(`status=DELETED`),需要在案件删除事件里显式清理本表,或在查询侧过滤 —— 这是最容易漏的一环。