cc 3 недель назад
Родитель
Сommit
2e2bf0283c

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

@@ -489,3 +489,89 @@ PowerShell 前台直接输出在本环境常被吞,写文件再 Read 才可靠
 - ★ 断言数字要按真实 DOM 算:2 个工具行只切了第 1 个到原始 JSON,所以表格数应 `=== 1` 而不是 `0`。
 - ★ **dist 时间戳比对**:改动前先确认 `dist` 构建时间**早于** src 改动时间,才能断定「旧行为是真 bug,
   不是构建过期」—— 这一步省掉会白改代码。
+
+### 关系图谱侧边栏两个入口按钮去掉背景色(2026-09-17)
+用户:"聊天界面的关系图谱点击对象出来的侧边栏,通话记录和交易记录按钮都保持无背景色。"
+- 文件:`ai-frontend/src/ai/components/blocks/GraphBlock.vue`
+- 原逻辑:侧边栏(`.ai-graph__detail-actions`)里两个按钮会按 `activeNodeKind`
+  (该节点关联边是否全是同一类型)给其中一个加 `is-primary`,表现为淡紫底 + 紫边框,
+  用户不想要这个"主入口"视觉。
+- 改动:模板去掉两处 `:class="{ 'is-primary': ... }"`(并顺手把两个 button 折成单行);
+  删除仅此处使用的 `activeNodeKind` computed;删除 `.ai-graph__btn.is-primary` CSS 规则与注释。
+  按钮回到 `.ai-graph__btn` 默认态(`background: transparent` + 透明边框),仅 hover 时高亮。
+- 未动 `resolveEdgeKind`(另有调用点)与连线兜底菜单 `.ai-graph__chooser-item`。
+- 验证:`node node_modules/eslint/bin/eslint.js --max-warnings 0 GraphBlock.vue` → **EXIT=0**(0 警告),
+  说明删掉的 computed 无残留引用。
+- ★ 本机 bash 缺 coreutils(`tail` / `sed` / `dirname` / `uname` 全 not found,`pnpm` 脚本因此跑不了),
+  要跑前端 CLI 得用 `node node_modules/<pkg>/bin/<x>.js` 直调。
+
+---
+
+## 关系图谱节点标识实体类型 → 点击节点按类型查明细(2026-09-17)
+
+需求(用户原话):`GraphRenderTool` 要"标识节点的类型(人 / 电话号码 / 银行卡号)",
+因为"查看详情时传入的参数不同 —— 是 personName,还是 personCard / personOtherCard / personPhone / otherPhone"。
+
+### 先认清的事实:两个弹框接口真实支持哪些字段(这是本次的"契约基准")
+前端 `CallRecordModal` / `TransRecordModal` 的 `buildQuery` 透传项,与后端逐条核对过:
+
+| 弹框 | 接口 | 人 | 号码 | 卡号 |
+|---|---|---|---|---|
+| 通话记录 | `POST /cr/getCallRecord`(`CallRecordQuery`) | `personName` / `otherName` | `personPhones[]` / `otherPhones[]`(`otherPhone` 单值) | **无**(只有 `personCertNo` 证件号,语义不同) |
+| 交易记录 | `POST /trans/getTransRecord`(`TransRecordQuery`) | `personName` / `otherName` | **无** | `personCardNo` / `otherCardNo` |
+
+- `personName` / `personNames` 在基类 `common/base/Query.java` 里(不在 `CallRecordQuery`);
+- 两个 mapper 都**确实**把这些字段拼进了 WHERE(`CallRecordMapper.xml:75/130/141/152`、
+  `TransRecordService` 的 lambda wrapper),所以不会出现"字段不认 → 退化成全表"的坑;
+- 卡号在通话记录、号码在交易记录**都没有对应字段** → 前端要置灰按钮并说明原因,而不是查出空表。
+
+### 后端改动
+- 新增 `common/enums/GraphNodeType.java`:`person` / `phone` / `card` / `other`,
+  照 `GraphEdgeType` 的写法带 `label` + `aliases` + `from()` + `allowedCodes()`;
+  归一化会去掉空白/下划线/连字符再比大小写,因此 `obj` `tel` `bank_card` `银行卡号` 都能认。
+- `GraphSpec.Node` 新增 `public String type`(**非必填**,`required=false`),`name` 的描述改为
+  "同时是查询该节点明细的关键字"(卡号节点别写「张三的卡」)。
+- `GraphRenderTool.validateAndNormalize`:节点 type 归一化成 code 并**写回**(前端总能拿到);
+  非法值报错列出取值;**缺失时按节点值兜底推断** —— 11 位手机号 → phone、
+  15-19 位纯数字(先去掉空格)→ card、其余 → person。人名不会长成这两种形态,所以推断很保守。
+- `@Tool.description`、`AgentService` 系统提示词(render prompt 第 3 条)、
+  `GraphAnalysisTool#get_case_graph` 的字段映射说明(补 `clazz→type`:obj→person、tel→phone、card→card)
+  三处同步更新。
+
+### 前端 `ai/components/blocks/GraphBlock.vue`
+- 新增 `NodeType` / `NODE_TYPE_ALIASES` / `NODE_TYPE_LABELS` / `resolveNodeType()`(认不出按 person,与老图谱行为一致)。
+- 新增 `SIDE_FIELDS`:**弹框 × 节点类型 → { mine, theirs }** 字段映射,缺项=该弹框查不了这种节点。
+  `SideField.array` 标记数组字段(`personPhones`/`otherPhones` 后端是 `List<String>`)。
+- ★ `fetchPairRecords` 泛化成 `fetchMergedRecords(kind, conditions[], query)`:
+  条件组数组,每组一次请求再合并去重(原双向合并逻辑不变),`PAIR_FETCH_LIMIT` 改名 `MERGE_FETCH_LIMIT`;
+  公共 query 里要先删掉 `CONDITION_FIELDS`(9 个),否则每组条件都会被同一个值锁死。
+- `pairContext`(只存 a/b 两个名字)→ `mergeContext`(存 conditions 数组),因为条件表达式不再只有
+  "personName + otherName" 一种形态。
+- **点击节点也变成两侧合并**:`[{personName:v},{otherName:v}]` / `[{personPhones:[v]},{otherPhones:[v]}]`
+  / `[{personCardNo:v},{otherCardNo:v}]`。理由:卡号/号码既可能落在记录的本方也可能落在对方字段,
+  只查一侧会漏;项目图谱页对 card 节点本来就是"该卡作为本方卡或对方卡都算"(`GraphService#nodeDetail`)。
+- 点击连线:`{source.mine, target.theirs}` + `{target.mine, source.theirs}`;某端在该弹框没有字段时
+  退化成"只按能表达的那一端查",而不是整条边查不了。
+- UI:侧边栏详情面板新增「类型」一行;两个入口按钮按类型 `disabled` + `title` 说明
+  (卡号节点的"通话记录"、号码节点的"交易记录"置灰);`chooser` 兜底菜单改存节点对象(原来存名字,拿不到类型)。
+
+### 行为变化(要记住)
+单节点明细查询从「服务端分页 + 只查本方字段」变成「两侧各查一次 + 客户端分页」,
+所以每组条件最多取 500 条(原双向合并的代价,现在扩大到了单节点场景)。
+
+### 验证
+- 后端 `mvn -pl ai-server -B test -Dtest=GraphRenderToolTest` → **Tests run: 15, Failures: 0**
+  (新增 3 个:缺失 type 的兜底推断、中文/旧写法归一化、非法 type 报错;schema 断言补了节点 type 可选+描述)
+- 前端 `eslint GraphBlock.vue --max-warnings 0` → 0;`vue-tsc --noEmit` → `src/ai` **0 错误**(全仓仍 103 基线);
+  `vite build --mode production` → **EXIT=0**
+- ★ 顺手修掉一个**既有**编译错误:`GraphRenderToolTest` 里 `spec.edges.get(0).value = 3.5`
+  对 `Integer value` 赋值(HEAD 就这样,测试一直编译不过)→ 改成整数 35 并更新 DisplayName。
+
+### 环境备忘(新增,很关键)
+- ★ **本机 bash 里 `mvn` 跑不了**:`/d/soft/apache-maven-3.9.12-bin/bin/mvn` 依赖 `uname`/`dirname`,
+  而 Git Bash 缺 coreutils → `ClassNotFoundException: ...launcher`。**必须用 PowerShell 调 `mvn.cmd`**:
+  `& "D:\soft\apache-maven-3.9.12-bin\bin\mvn.cmd" -pl ai-server -B test "-Dtest=xxx"`。
+- 跑长命令用 `> 日志文件 2>&1` 落盘再读;在 bash 里用 `node -e` 过滤日志时,正则里的 `\.java:\[`
+  会被 shell 转义搞坏(`Unterminated regexp literal`),别把复杂正则塞进命令行。
+
+

+ 268 - 78
ai-frontend/src/ai/components/blocks/GraphBlock.vue

@@ -102,6 +102,8 @@
           <button class="ai-graph__btn" type="button" @click="activeNode = null">关闭</button>
         </div>
         <dl class="ai-graph__detail-list">
+          <dt>类型</dt>
+          <dd>{{ activeNodeTypeLabel }}</dd>
           <template v-if="activeNode.category">
             <dt>分类</dt>
             <dd>{{ activeNode.category }}</dd>
@@ -117,14 +119,16 @@
         </dl>
 
         <!--
-          节点没有像连线那样的类型文字,无法一眼看出该查通话还是交易,
-          因此两个入口都给出;若该节点的关联边全是同一种类型,则把那个入口做成主按钮。
+          两个入口都给出,但可用性取决于节点的实体类型:号码节点只能查通话、卡号节点只能查交易
+          (对应弹框没有该类型的查询字段时置灰,并把原因写进 title)。
+          样式上两个按钮一律保持无背景色,只有悬停时才给出反馈。
         -->
         <div class="ai-graph__detail-actions">
           <button
             class="ai-graph__btn"
-            :class="{ 'is-primary': activeNodeKind === 'call' }"
             type="button"
+            :disabled="nodeRecordDisabled('call')"
+            :title="nodeRecordTitle('call')"
             @click="openActiveRecords('call')"
           >
             <Icon icon="mdi:phone-outline" :size="13" />
@@ -132,8 +136,9 @@
           </button>
           <button
             class="ai-graph__btn"
-            :class="{ 'is-primary': activeNodeKind === 'trans' }"
             type="button"
+            :disabled="nodeRecordDisabled('trans')"
+            :title="nodeRecordTitle('trans')"
             @click="openActiveRecords('trans')"
           >
             <Icon icon="mdi:bank-transfer" :size="13" />
@@ -154,7 +159,7 @@
     -->
     <div v-if="chooser" ref="chooserRef" class="ai-graph__chooser" :style="chooserStyle">
       <div class="ai-graph__chooser-head">
-        {{ chooser.a }} ⇄ {{ chooser.b }}
+        {{ nodeDisplayName(chooser.source) }} ⇄ {{ nodeDisplayName(chooser.target) }}
         <span v-if="chooser.label" class="ai-graph__chooser-label">{{ chooser.label }}</span>
       </div>
       <div v-if="chooser.hint" class="ai-graph__chooser-hint">{{ chooser.hint }}</div>
@@ -205,6 +210,7 @@
   } from '@relation-graph/vue';
   import '@relation-graph/vue/style.css';
   import Icon from '@/core/components/Icon/src/Icon.vue';
+  import { useMessage } from '@/core/hooks/web/useMessage';
   import CallRecordModal from '@/call/views/components/CallRecordModal.vue';
   import { getCallRecord } from '@/call/api/callApi';
   import TransRecordModal from '@/trans/views/components/TransRecordModal.vue';
@@ -214,6 +220,14 @@
   interface GraphNode {
     id: string;
     name?: string;
+    /**
+     * 节点实体类型,由后端 render_graph 规范化后输出:person(人)/ phone(电话号码)/
+     * card(银行卡号)/ other(其他实体)。
+     * 它决定点击该节点查明细时用哪个查询参数(见 SIDE_FIELDS):
+     * 人按 personName、号码按 personPhones、卡号按 personCardNo。
+     * 老图谱没有这个字段,此时按 person 处理。
+     */
+    type?: string;
     category?: string;
     value?: number | string;
     desc?: string;
@@ -253,6 +267,8 @@
    */
   const CATEGORY_COLORS = ['#2a50ec', '#1a7f37', '#bc4c00', '#cf222e', '#8250df', '#0969da', '#6e7781', '#9a6700'];
 
+  const { showMessage } = useMessage();
+
   /**
    * 图谱实例句柄。
    *
@@ -375,11 +391,123 @@
    */
   const RECORD_MODAL_Z_INDEX = 1015;
 
-  /** 两点双向合并查询时,单方向最多取多少条(分页只能在客户端做) */
-  const PAIR_FETCH_LIMIT = 500;
+  /** 合并取数时每组条件最多取多少条(分页只能在客户端做) */
+  const MERGE_FETCH_LIMIT = 500;
 
   type RecordKind = 'call' | 'trans';
 
+  const RECORD_KIND_LABELS: Record<RecordKind, string> = { call: '通话记录', trans: '交易记录' };
+
+  /**
+   * 节点的实体类型(后端 GraphNodeType 的 code)。
+   *
+   * 与边的 type 不同:边的 type 决定「打开哪个弹框」,节点的 type 决定「用哪个字段查」。
+   */
+  type NodeType = 'person' | 'phone' | 'card' | 'other';
+
+  /**
+   * 节点类型别名。归一化后再查表(去空白/下划线/连字符 + 转小写),
+   * 因此表里的键都是紧凑小写形式;中文与项目内部旧写法(obj / tel)一并兜住。
+   */
+  const NODE_TYPE_ALIASES = new Map<string, NodeType>([
+    ['person', 'person'],
+    ['obj', 'person'],
+    ['people', 'person'],
+    ['ren', 'person'],
+    ['personobj', 'person'],
+    ['人', 'person'],
+    ['人员', 'person'],
+    ['自然人', 'person'],
+    ['phone', 'phone'],
+    ['tel', 'phone'],
+    ['mobile', 'phone'],
+    ['phoneno', 'phone'],
+    ['telno', 'phone'],
+    ['电话', 'phone'],
+    ['号码', 'phone'],
+    ['手机号', 'phone'],
+    ['手机号码', 'phone'],
+    ['电话号码', 'phone'],
+    ['card', 'card'],
+    ['bankcard', 'card'],
+    ['cardno', 'card'],
+    ['卡', 'card'],
+    ['卡号', 'card'],
+    ['银行卡', 'card'],
+    ['银行卡号', 'card'],
+    ['账号', 'card'],
+    ['账户', 'card'],
+    ['other', 'other'],
+    ['其他', 'other'],
+    ['其它', 'other'],
+  ]);
+
+  const NODE_TYPE_LABELS: Record<NodeType, string> = {
+    person: '人',
+    phone: '电话号码',
+    card: '银行卡号',
+    other: '其他',
+  };
+
+  /** 节点实体类型;未标识或标识不认识时按人处理(与老图谱的既有行为一致) */
+  function resolveNodeType(node?: GraphNode | null): NodeType {
+    const key = String(node?.type ?? '')
+      .trim()
+      .toLowerCase()
+      .replace(/[\s_-]+/g, '');
+    return NODE_TYPE_ALIASES.get(key) ?? 'person';
+  }
+
+  /** 详情面板展示用的节点类型名 */
+  const activeNodeTypeLabel = computed(() => NODE_TYPE_LABELS[resolveNodeType(activeNode.value)]);
+
+  /**
+   * 一个实体在某个弹框里的查询字段。
+   *
+   * <p>array=true 的字段后端收数组(通话的 personPhones / otherPhones 是 {@code List<String>})。
+   */
+  interface SideField {
+    field: string;
+    array?: boolean;
+  }
+
+  /**
+   * 弹框 × 节点类型 → 本方(mine)/ 对方(theirs)查询字段。
+   *
+   * <p>字段名严格对齐两个弹框的取数契约({@code CallRecordModal} / {@code TransRecordModal} 的 buildQuery 透传项):
+   * <ul>
+   *   <li>通话记录(CallRecordQuery)支持 personName / otherName / personPhones / otherPhones;</li>
+   *   <li>交易记录(TransRecordQuery)支持 personName / otherName / personCardNo / otherCardNo。</li>
+   * </ul>
+   * 因此卡片号在通话记录里、号码在交易记录里都没有对应字段,缺项即表示「该弹框查不了这种节点」。
+   */
+  const SIDE_FIELDS: Record<RecordKind, Partial<Record<NodeType, { mine: SideField; theirs: SideField }>>> = {
+    call: {
+      person: { mine: { field: 'personName' }, theirs: { field: 'otherName' } },
+      phone: { mine: { field: 'personPhones', array: true }, theirs: { field: 'otherPhones', array: true } },
+    },
+    trans: {
+      person: { mine: { field: 'personName' }, theirs: { field: 'otherName' } },
+      card: { mine: { field: 'personCardNo' }, theirs: { field: 'otherCardNo' } },
+    },
+  };
+
+  /** 一组查询条件(可含多个字段,例如「人是本方 且 卡是对方」) */
+  type RecordConditions = Record<string, unknown>;
+
+  /** 会被条件组覆盖的字段:合并取数时要先从公共查询条件里摘掉,否则两路会被同一组条件锁死 */
+  const CONDITION_FIELDS = [
+    'personName',
+    'personNames',
+    'otherName',
+    'personPhones',
+    'otherPhones',
+    'personPhone',
+    'otherPhone',
+    'personCardNo',
+    'otherCardNo',
+  ];
+
   /**
    * 边的语义类型判定结果:
    * - `call` / `trans`:可直接打开对应明细弹框
@@ -464,10 +592,15 @@
   const callBaseQuery = ref<Record<string, unknown>>({});
   const transBaseQuery = ref<Record<string, unknown>>({});
   /**
-   * 「两点之间」查询上下文;为 null 表示按单人(节点)查询。
-   * 有值时启用自定义取数:把 A→B 与 B→A 两个方向合并后再分页。
+   * 自定义取数上下文:有值时按 conditions 里的多组条件分别取数再合并(客户端分页)。
+   *
+   * 两种情况都会走这里:
+   * ① 点击连线 —— 两点之间,正反两个方向各一组条件;
+   * ② 点击节点 —— 单个实体,本方侧与对方侧各一组条件。
+   * 卡号/号码既可能落在记录的本方字段也可能落在对方字段,只查一侧会漏数据
+   * (项目图谱页对 card 节点同样是「该卡作为本方卡或对方卡都算」的口径)。
    */
-  const pairContext = ref<{ kind: RecordKind; a: string; b: string } | null>(null);
+  const mergeContext = ref<{ kind: RecordKind; conditions: RecordConditions[] } | null>(null);
 
   /**
    * 连线点击时 relation-graph 只交出库内部的 line 对象,用 id 反查原始边。
@@ -481,10 +614,71 @@
     return edges.value.find((item) => String(item.source) === from && String(item.target) === to);
   }
 
-  /** 节点 id → 节点显示名(后端记录表里存的是人名,不是图节点 id) */
-  function nodeDisplayName(id: string): string {
-    const found = nodes.value.find((item) => String(item.id) === String(id));
-    return String(found?.name ?? found?.id ?? '').trim();
+  /** 节点 id → 节点对象 */
+  function findNode(id: string): GraphNode | undefined {
+    return nodes.value.find((item) => String(item.id) === String(id));
+  }
+
+  /** 节点显示名(后端记录表里存的是人名 / 号码 / 卡号,不是图节点 id) */
+  function nodeDisplayName(node?: GraphNode | null): string {
+    return String(node?.name ?? node?.id ?? '').trim();
+  }
+
+  /** 节点用于查明细的关键字:必须取记录表里的原始值,所以优先 name、其次 id */
+  function nodeKeyword(node?: GraphNode | null): string {
+    return String(node?.name ?? node?.id ?? '').trim();
+  }
+
+  function buildCondition(side: SideField, value: string): RecordConditions {
+    return { [side.field]: side.array ? [value] : value };
+  }
+
+  /** 单个实体:本方侧 + 对方侧各一组条件 */
+  function buildNodeConditions(kind: RecordKind, node: GraphNode): RecordConditions[] {
+    const fields = SIDE_FIELDS[kind][resolveNodeType(node)];
+    const value = nodeKeyword(node);
+    if (!fields || !value) return [];
+    return [buildCondition(fields.mine, value), buildCondition(fields.theirs, value)];
+  }
+
+  /**
+   * 连线两端:正反两个方向各一组条件(A 本方·B 对方,以及 B 本方·A 对方)。
+   *
+   * 任一端在该弹框里没有对应字段时(例如用通话记录查卡号节点),
+   * 退化成「只按能表达的那一端查」,而不是整条边都查不了。
+   */
+  function buildPairConditions(kind: RecordKind, source?: GraphNode, target?: GraphNode): RecordConditions[] {
+    const sourceFields = source ? SIDE_FIELDS[kind][resolveNodeType(source)] : undefined;
+    const targetFields = target ? SIDE_FIELDS[kind][resolveNodeType(target)] : undefined;
+    const a = nodeKeyword(source);
+    const b = nodeKeyword(target);
+    const conditions: RecordConditions[] = [];
+    if (sourceFields && targetFields && a && b) {
+      conditions.push({ ...buildCondition(sourceFields.mine, a), ...buildCondition(targetFields.theirs, b) });
+      conditions.push({ ...buildCondition(targetFields.mine, b), ...buildCondition(sourceFields.theirs, a) });
+      return conditions;
+    }
+    if (sourceFields && a) conditions.push(buildCondition(sourceFields.mine, a));
+    if (targetFields && b) conditions.push(buildCondition(targetFields.mine, b));
+    return conditions;
+  }
+
+  /** 该弹框查不了这种节点时的提示文案 */
+  function unsupportedHint(kind: RecordKind, node?: GraphNode | null): string {
+    return `${RECORD_KIND_LABELS[kind]}不支持按「${NODE_TYPE_LABELS[resolveNodeType(node)]}」查询,该弹框没有对应字段`;
+  }
+
+  /** 详情面板的某个明细入口是否可用(该弹框是否有这种节点的查询字段) */
+  function nodeRecordDisabled(kind: RecordKind): boolean {
+    const node = activeNode.value;
+    return !node || !SIDE_FIELDS[kind][resolveNodeType(node)];
+  }
+
+  /** 详情面板入口的悬停说明:可用时写清按哪个字段查、查哪两侧;不可用时说明原因 */
+  function nodeRecordTitle(kind: RecordKind): string {
+    const node = activeNode.value;
+    if (nodeRecordDisabled(kind)) return unsupportedHint(kind, node);
+    return `${RECORD_KIND_LABELS[kind]}(按${NODE_TYPE_LABELS[resolveNodeType(node)]}查询,含本方与对方两侧)`;
   }
 
   function openCallModal(query: Record<string, unknown>, title: string) {
@@ -501,45 +695,48 @@
     transOpen.value = true;
   }
 
-  /** 两点之间:双向合并查询 */
-  function openPairRecords(kind: RecordKind, a: string, b: string) {
-    pairContext.value = { kind, a, b };
-    if (kind === 'call') openCallModal({ personName: a, otherName: b }, `通话记录 · ${a} ⇄ ${b}`);
-    else openTransModal({ personName: a, otherName: b }, `交易记录 · ${a} ⇄ ${b}`);
+  /** 打开明细弹框:条件组交给自定义取数,第一组条件同时作为弹框的初始筛选值 */
+  function openRecords(kind: RecordKind, conditions: RecordConditions[], title: string) {
+    mergeContext.value = { kind, conditions };
+    const baseQuery = { ...conditions[0] };
+    if (kind === 'call') openCallModal(baseQuery, title);
+    else openTransModal(baseQuery, title);
   }
 
-  /** 单个人:交给弹框默认取数,走服务端分页 */
-  function openPersonRecords(kind: RecordKind, name: string) {
-    pairContext.value = null;
-    if (kind === 'call') openCallModal({ personName: name }, `通话记录 · ${name}`);
-    else openTransModal({ personName: name }, `交易记录 · ${name}`);
+  /** 连线:两端节点的实体类型决定查询字段 */
+  function openPairRecords(kind: RecordKind, source?: GraphNode, target?: GraphNode) {
+    const conditions = buildPairConditions(kind, source, target);
+    if (!conditions.length) {
+      showMessage(unsupportedHint(kind, source ?? target), 'warning');
+      return;
+    }
+    openRecords(
+      kind,
+      conditions,
+      `${RECORD_KIND_LABELS[kind]} · ${nodeDisplayName(source)} ⇄ ${nodeDisplayName(target)}`,
+    );
+  }
+
+  /** 节点:该实体的类型决定查询字段 */
+  function openNodeRecords(kind: RecordKind, node: GraphNode) {
+    const conditions = buildNodeConditions(kind, node);
+    if (!conditions.length) {
+      showMessage(unsupportedHint(kind, node), 'warning');
+      return;
+    }
+    openRecords(kind, conditions, `${RECORD_KIND_LABELS[kind]} · ${nodeDisplayName(node)}`);
   }
 
   function openActiveRecords(kind: RecordKind) {
     const node = activeNode.value;
     if (!node) return;
-    openPersonRecords(kind, String(node.name ?? node.id));
+    openNodeRecords(kind, node);
   }
 
-  /** 详情节点的关联边类型:关联边全是同一种时返回该类型,混合或识别不出返回空 */
-  const activeNodeKind = computed<RecordKind | ''>(() => {
-    const node = activeNode.value;
-    if (!node) return '';
-    const kinds = new Set<RecordKind>();
-    for (const edge of edges.value) {
-      if (String(edge.source) !== String(node.id) && String(edge.target) !== String(node.id)) continue;
-      const kind = resolveEdgeKind(edge);
-      // other 表示明确没有对应明细,不参与「该节点该查哪种明细」的判断
-      if (kind === 'call' || kind === 'trans') kinds.add(kind);
-    }
-    const [firstKind] = kinds;
-    return kinds.size === 1 ? (firstKind ?? '') : '';
-  });
-
   /** 兜底菜单:类型判不出(老图谱)或明确是「其他」时,固定在鼠标位置让用户手选 */
   const chooser = ref<{
-    a: string;
-    b: string;
+    source: GraphNode;
+    target: GraphNode;
     label: string;
     hint: string;
     x: number;
@@ -576,14 +773,14 @@
   function onLineClick(line: any, _link: any, e?: MouseEvent) {
     const edge = resolveEdge(line);
     if (!edge) return;
-    const a = nodeDisplayName(String(edge.source));
-    const b = nodeDisplayName(String(edge.target));
-    if (!a || !b) return;
+    const source = findNode(String(edge.source));
+    const target = findNode(String(edge.target));
+    if (!source || !target) return;
 
     const kind = resolveEdgeKind(edge);
     if (kind === 'call' || kind === 'trans') {
       closeChooser();
-      openPairRecords(kind, a, b);
+      openPairRecords(kind, source, target);
       return;
     }
 
@@ -591,8 +788,8 @@
     const x = Math.min(Math.max(Number(e?.clientX ?? 0) + 4, 8), Math.max(window.innerWidth - 198, 8));
     const y = Math.min(Math.max(Number(e?.clientY ?? 0) + 4, 8), Math.max(window.innerHeight - 118, 8));
     chooser.value = {
-      a,
-      b,
+      source,
+      target,
       label: String(edge.label || ''),
       hint:
         kind === 'other'
@@ -606,7 +803,7 @@
   function pickChooser(kind: RecordKind) {
     const target = chooser.value;
     closeChooser();
-    if (target) openPairRecords(kind, target.a, target.b);
+    if (target) openPairRecords(kind, target.source, target.target);
   }
 
   /** 兼容弹框内部的列表兜底:后端 Result 包裹层级与裸数组两种形态都出现过 */
@@ -640,33 +837,33 @@
   }
 
   /**
-   * 两点之间的明细取数:同时查 A→B 与 B→A 再合并去重。
+   * 多条件合并取数:conditions 里每组条件各查一次再合并去重。
    *
-   * 后端 personName / otherName 都是精确匹配,只查一个方向会漏掉反向记录
-   * (项目图谱页用的是同样的双向合并思路,见 GraphTransDetailQuery 的 lx=1)。
-   * 代价是分页只能在客户端做,所以单方向限取 PAIR_FETCH_LIMIT 条。
+   * 后端这些字段都是精确匹配,只看一个方向(或一侧)会漏记录 —— 项目图谱页用的是同样的
+   * 双向合并思路(见 GraphTransDetailQuery 的 lx=1)。代价是分页只能在客户端做,
+   * 因此每组条件限取 MERGE_FETCH_LIMIT 条。
    */
-  async function fetchPairRecords(kind: RecordKind, a: string, b: string, query: any) {
+  async function fetchMergedRecords(kind: RecordKind, conditions: RecordConditions[], query: any) {
     const api: (data: any) => Promise<any> = kind === 'call' ? getCallRecord : getTransRecord;
     const shared: Record<string, unknown> = { ...(query || {}) };
-    delete shared.personName;
-    delete shared.otherName;
-    delete shared.personNames;
+    // 条件组自带这些字段,必须从公共条件里摘掉,否则每组都会被同一个值锁死
+    for (const field of CONDITION_FIELDS) delete shared[field];
     delete shared.page;
     delete shared.limit;
 
-    const [forward, backward] = await Promise.all([
-      api({ ...shared, personName: a, otherName: b, page: 1, limit: PAIR_FETCH_LIMIT }),
-      api({ ...shared, personName: b, otherName: a, page: 1, limit: PAIR_FETCH_LIMIT }),
-    ]);
+    const responses = await Promise.all(
+      conditions.map((condition) => api({ ...shared, ...condition, page: 1, limit: MERGE_FETCH_LIMIT })),
+    );
 
     const seen = new Set<string>();
     const rows: any[] = [];
-    for (const row of [...toRecordList(forward), ...toRecordList(backward)]) {
-      const key = row?.id === undefined || row?.id === null ? JSON.stringify(row) : String(row.id);
-      if (seen.has(key)) continue;
-      seen.add(key);
-      rows.push(row);
+    for (const response of responses) {
+      for (const row of toRecordList(response)) {
+        const key = row?.id === undefined || row?.id === null ? JSON.stringify(row) : String(row.id);
+        if (seen.has(key)) continue;
+        seen.add(key);
+        rows.push(row);
+      }
     }
 
     const orderKey = String(query?.orderKey || '');
@@ -694,17 +891,17 @@
     };
   }
 
-  /** 只有「两点之间」模式才接管取数;按单人查询时返回 undefined,让弹框走默认接口 */
+  /** 有自定义取数上下文时接管取数(条件组在外部按节点类型算好),否则返回 undefined 让弹框走默认接口 */
   const callFetchApi = computed(() => {
-    const context = pairContext.value;
+    const context = mergeContext.value;
     if (context?.kind !== 'call') return undefined;
-    return (query: any) => fetchPairRecords('call', context.a, context.b, query);
+    return (query: any) => fetchMergedRecords('call', context.conditions, query);
   });
 
   const transFetchApi = computed(() => {
-    const context = pairContext.value;
+    const context = mergeContext.value;
     if (context?.kind !== 'trans') return undefined;
-    return (query: any) => fetchPairRecords('trans', context.a, context.b, query);
+    return (query: any) => fetchMergedRecords('trans', context.conditions, query);
   });
 
   function fitView() {
@@ -980,13 +1177,6 @@
         background: var(--ai-primary-soft);
       }
 
-      /* 详情面板里推断出的主入口(该节点关联边全是同一种类型时) */
-      &.is-primary {
-        color: var(--ai-primary);
-        border-color: fade(@primary-color, 45%);
-        background: var(--ai-primary-soft);
-      }
-
       &:disabled {
         opacity: 0.45;
         cursor: not-allowed;

+ 127 - 0
ai-server/src/main/java/com/zsjz/ai/common/enums/GraphNodeType.java

@@ -0,0 +1,127 @@
+package com.zsjz.ai.common.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.List;
+
+/**
+ * 关系图谱节点的实体类型(render_graph 输出契约)。
+ *
+ * <p>前端据此决定点击这个节点查明细时该用哪个查询参数:
+ * <ul>
+ *   <li>{@link #PERSON} 人 → 通话/交易弹框都按 {@code personName}(本方姓名)查;</li>
+ *   <li>{@link #PHONE} 电话号码 → 通话记录按 {@code personPhones}(本方号码)查,
+ *       号码不是交易记录的字段;</li>
+ *   <li>{@link #CARD} 银行卡号/账号 → 交易记录按 {@code personCardNo}(本方卡号)查,
+ *       卡号不是通话记录的字段;</li>
+ *   <li>{@link #OTHER} 其他(案件、公司、地址、车辆…)→ 没有对应明细。</li>
+ * </ul>
+ *
+ * <p>取名与 {@code GraphService} 内部节点类型(card / tel / obj)不同,是为了让模型
+ * 一眼看懂实体是什么;旧写法(obj / tel / 卡号 / 号码 等)由 {@link #from(String)} 兜住。
+ * {@link #code} 会原样透传给前端,<b>不要随意改名</b>;确需新增类型时只追加。
+ *
+ * @author cc
+ */
+@Getter
+@AllArgsConstructor
+public enum GraphNodeType {
+
+    /**
+     * 人(姓名是查询键)
+     */
+    PERSON("person", "人", List.of("obj", "people", "ren", "person_obj", "人员", "自然人", "嫌疑人")),
+
+    /**
+     * 电话号码(号码本身是查询键)
+     */
+    PHONE("phone", "电话号码", List.of("tel", "mobile", "phone_no", "tel_no", "电话", "号码", "手机号", "手机号码")),
+
+    /**
+     * 银行卡号 / 账号(卡号本身是查询键)
+     */
+    CARD("card", "银行卡号", List.of("bank_card", "bankcard", "card_no", "cardno", "卡", "卡号", "银行卡", "账号", "账户", "账户号")),
+
+    /**
+     * 其他实体(案件、公司、地址、车辆…),没有通话/交易明细可查
+     */
+    OTHER("other", "其他", List.of("misc", "其它", "其它类型")),
+    ;
+
+    /**
+     * 透传给前端的取值
+     */
+    private final String code;
+
+    /**
+     * 中文名,允许模型直接输出中文
+     */
+    private final String label;
+
+    /**
+     * 宽松匹配用的别名(内部旧写法、英文变体、口语写法)
+     */
+    private final List<String> aliases;
+
+    /**
+     * 宽松匹配:code / 中文名 / 别名都接受,大小写、下划线、连字符、首尾空白不敏感。
+     *
+     * <p>之所以容忍中文与 {@code tel} / {@code obj} 这类旧写法,是因为模型的取值来源不统一
+     * (可能照抄 {@code get_case_graph} 的 clazz,也可能自己写「电话号码」),
+     * 这属于显式声明类型,与「从名称里猜」不是一回事,兜住它可以少一轮重试。
+     *
+     * @param value 模型输出的 type 原始值
+     * @return 匹配不到返回 null
+     */
+    public static GraphNodeType from(String value) {
+        if (value == null) {
+            return null;
+        }
+        String text = normalize(value);
+        if (text.isEmpty()) {
+            return null;
+        }
+        for (GraphNodeType item : values()) {
+            if (normalize(item.code).equals(text) || normalize(item.label).equals(text)) {
+                return item;
+            }
+            for (String alias : item.aliases) {
+                if (normalize(alias).equals(text)) {
+                    return item;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 允许的取值说明,拼进报错信息里让模型能自我修正
+     *
+     * @return 形如 {@code person(人) / phone(电话号码) / card(银行卡号) / other(其他)}
+     */
+    public static String allowedCodes() {
+        StringBuilder builder = new StringBuilder();
+        for (GraphNodeType item : values()) {
+            if (!builder.isEmpty()) {
+                builder.append(" / ");
+            }
+            builder.append(item.code).append('(').append(item.label).append(')');
+        }
+        return builder.toString();
+    }
+
+    /**
+     * 归一化:去掉空白、下划线、连字符后转小写,避免 {@code bank_card} / {@code Bank-Card} 之类的写法漏匹配
+     */
+    private static String normalize(String value) {
+        StringBuilder builder = new StringBuilder(value.length());
+        for (char c : value.toCharArray()) {
+            if (Character.isWhitespace(c) || c == '_' || c == '-') {
+                continue;
+            }
+            builder.append(Character.toLowerCase(c));
+        }
+        return builder.toString();
+    }
+}

+ 1 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -256,6 +256,7 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
                 2. 展示常规图表(柱状/折线/饼/散点等)时,必须先调用 render_chart 工具校验 option(option 是结构化 JSON 对象,按该工具的 schema 传,不要传 JSON 字符串),再把工具返回的 ECharts option JSON 原样放入 ```echarts 围栏块中,前端会渲染为图表。
                 3. 展示关系图谱/网络图/关联图时,禁止用 render_chart 手写 graph 类型 series(会被拒绝),必须先调用 render_graph 工具(入参结构以该工具的 schema 为准,不要再自己拼结构串),再把工具返回的图谱 JSON 原样放入 ```graph 围栏块中,前端会渲染为可交互力导向图。禁止用表格或文字罗列代替。
                    render_graph 的 edges[].type 决定用户点击这条边时前端打开通话记录还是交易记录弹框,必须依据这条边实际来自哪张表来填(来自 call_record 填 call,来自 trans_record 填 trans),**不要按 label 的文字去猜**;不是这两种数据(如仅由 person_record、案件表推导出的关联)填 other。缺失或取值非法会被 render_graph 拒绝并要求重填。
+                   同理,nodes[].type 决定用户点击这个节点时前端按哪个字段查明细:person=人(按 personName 查,通话/交易都支持)、phone=电话号码(通话记录按 personPhones 查)、card=银行卡号(交易记录按 personCardNo 查)、other=其他实体(无明细)。节点值是人名填 person,是号码填 phone,是卡号填 card,**不要一律填 person**;nodes[].name 要填可查询的原始值(人名/号码/卡号),不能是「张三的卡」这类别名,否则点开查不到明细。
                 4. 结论、摘要、分析说明正常用 markdown 书写;凡是需要用户查看的数据明细,一律用上述围栏块呈现。""";
         return sysPrompt == null || sysPrompt.isBlank() ? renderPrompt.strip() : sysPrompt + renderPrompt;
     }

+ 3 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GraphAnalysisTool.java

@@ -106,7 +106,9 @@ public class GraphAnalysisTool {
                   + "\"edgeList\":[{\"node1\":起点node,\"node2\":终点node,\"node1cn\":起点名,\"node2cn\":终点名,"
                   + "\"mc\":关系名称,\"clazz\":边类型(card_card=资金/tel_tel=通话/其他=其他关系),"
                   + "\"num\":关系权重(金额或次数,字符串),\"fx\":方向(1/2),\"lx\":对方类型(1卡2人3其他4无)}]}。"
-                  + "喂给 render_graph 的字段映射:nodeList[].node→nodes[].id、nodecn→name、clazz→category;"
+                  + "喂给 render_graph 的字段映射:nodeList[].node→nodes[].id、nodecn→name、clazz→category,"
+                  + "同时把 nodeList[].clazz 映射成 nodes[].type:obj→person、tel→phone、card→card"
+                  + "(type 决定用户点击该节点时按哪个字段查明细,别漏);"
                   + "edgeList[].node1→edges[].source、node2→edges[].target、mc→label、num→value,"
                   + "edges[].type 由 clazz 决定:card_card→trans、tel_tel→call、其余→other。"
                   + "edgeList 为空表示这些人员之间没有符合条件的关系。"

+ 51 - 3
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GraphRenderTool.java

@@ -1,6 +1,7 @@
 package com.zsjz.ai.module.agent.tools;
 
 import com.zsjz.ai.common.enums.GraphEdgeType;
+import com.zsjz.ai.common.enums.GraphNodeType;
 import com.zsjz.ai.common.utils.Json;
 import io.agentscope.core.message.ToolResultBlock;
 import io.agentscope.core.tool.Tool;
@@ -12,6 +13,7 @@ import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.regex.Pattern;
 
 /**
  * Agent 关系图谱渲染工具(render_graph)
@@ -30,8 +32,9 @@ import java.util.Set;
  * {@link ToolResultBlock}:成功带 SUCCESS 状态 + 干净 JSON,失败带 ERROR 状态 + 问题清单。
  *
  * <p>本工具在框架校验(必填/类型)之上做语义校验:引用完整性、规模上限,
- * 并把边的 {@code type} 归一化成 {@link GraphEdgeType} 的 code,
- * 前端据此决定点击该边时打开哪种明细弹框。
+ * 并把边的 {@code type} 归一化成 {@link GraphEdgeType} 的 code、
+ * 把节点的 {@code type} 归一化成 {@link GraphNodeType} 的 code,
+ * 前端据此决定点击该边/该节点时打开哪种明细弹框、用哪个字段查。
  */
 @Slf4j
 public class GraphRenderTool {
@@ -42,6 +45,12 @@ public class GraphRenderTool {
     /** 关系图谱边数上限 */
     private static final int MAX_GRAPH_EDGES = 500;
 
+    /** 手机号(11 位,1 开头且第二位 3-9),用于节点未声明 type 时的兜底推断 */
+    private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
+
+    /** 银行卡号 / 账号(15-19 位纯数字,卡号里的空格已先被去掉) */
+    private static final Pattern CARD_PATTERN = Pattern.compile("^\\d{15,19}$");
+
     /**
      * 将关系数据渲染为力导向关系图谱(节点/边结构化 JSON)
      *
@@ -52,6 +61,12 @@ public class GraphRenderTool {
                   + "节点/边数据基于已通过 execute_sql 查询到的关系数据组装;"
                   + "nodes.id 必须唯一且被 edges 的 source/target 引用;category 用于分区着色。"
                   + "用户需要『关系图谱』『人-人/人-案关联』『网络图』类可视化时必须调用本工具。"
+                  + "★ 每个节点的 type 标识该节点是什么实体(person 人 / phone 电话号码 / card 银行卡号 / other 其他),"
+                  + "它决定用户点击这个节点时前端拿哪个字段查明细:"
+                  + "人按 personName、电话号码按 personPhones(通话明细)、银行卡号按 personCardNo(交易明细)查,"
+                  + "所以 nodes[].name 要填可查询的原始值(人名 / 号码 / 卡号),不能是别名或备注。"
+                  + "type 省略时会按节点值自动判断(11 位手机号判为 phone、15-19 位纯数字判为 card),"
+                  + "但卡号/号码节点多的图谱务必显式填写,避免判错导致点开没有明细。"
                   + "★ 每条边的 type 必填且只能取 call / trans / other,"
                   + "它决定用户点击这条边时前端打开通话记录还是交易记录弹框:"
                   + "边来自 call_record 填 call,来自 trans_record 填 trans,"
@@ -74,7 +89,8 @@ public class GraphRenderTool {
     /**
      * 图谱语义校验 + 规范化:nodes 非空、必填字段、引用完整性、规模上限,并把边的 type 统一成 code。
      *
-     * <p>会就地修改 {@code graph}:edges[].type 被改写为 {@link GraphEdgeType#getCode()}。
+     * <p>会就地修改 {@code graph}:edges[].type 被改写为 {@link GraphEdgeType#getCode()},
+     * nodes[].type 被改写为 {@link GraphNodeType#getCode()}(缺失时按节点值兜底推断)。
      *
      * <p>注意:必填字段与字段类型已由 AgentScope 在调用前按 {@link GraphSpec} 生成的
      * JSON Schema 校验过(缺失会直接返回「Parameter validation failed」),
@@ -108,6 +124,18 @@ public class GraphRenderTool {
             if (!StringUtils.hasText(node.name)) {
                 problems.add("节点 " + id + " 缺失 name");
             }
+
+            GraphNodeType nodeType = GraphNodeType.from(node.type);
+            if (nodeType == null) {
+                if (StringUtils.hasText(node.type)) {
+                    problems.add("节点 " + id + " 的 type 非法: " + node.type
+                            + "(取值:" + GraphNodeType.allowedCodes() + ")");
+                    continue;
+                }
+                // 未声明类型时按节点值兜底判断(号码/卡号形态可自动识别,其余按人处理)
+                nodeType = detectNodeType(node);
+            }
+            node.type = nodeType.getCode();
         }
 
         List<GraphSpec.Edge> edges = graph.edges;
@@ -148,4 +176,24 @@ public class GraphRenderTool {
         }
         return problems;
     }
+
+    /**
+     * 节点未声明 {@code type} 时,按节点值推断实体类型。
+     *
+     * <p>只认两种稳定形态:11 位手机号 → {@link GraphNodeType#PHONE},
+     * 15-19 位纯数字(含空格分隔的卡号)→ {@link GraphNodeType#CARD};
+     * 其余一律按人处理。人名不会长成这样,所以推断很保守,
+     * 目的是让模型偶发漏填时前端仍能按正确字段查到明细。
+     */
+    private static GraphNodeType detectNodeType(GraphSpec.Node node) {
+        String raw = StringUtils.hasText(node.name) ? node.name : node.id;
+        String text = raw == null ? "" : raw.replaceAll("\\s+", "");
+        if (PHONE_PATTERN.matcher(text).matches()) {
+            return GraphNodeType.PHONE;
+        }
+        if (CARD_PATTERN.matcher(text).matches()) {
+            return GraphNodeType.CARD;
+        }
+        return GraphNodeType.PERSON;
+    }
 }

+ 24 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GraphSpec.java

@@ -33,6 +33,8 @@ import java.util.List;
  * 且一旦写成枚举,schema 的 enum 校验会在工具执行前直接拒绝中文取值(模型偶尔会填「通话」)。
  * 现在保留 String + 工具内 {@link com.zsjz.ai.common.enums.GraphEdgeType#from(String)} 归一化,
  * 中文/大小写/空格都能自动纠正成 code,错误信息里带上合法取值。
+ *
+ * <p>节点 {@code Node.type} 同样用 String + {@code GraphNodeType#from(String)} 归一化,理由一致。
  */
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class GraphSpec {
@@ -63,9 +65,30 @@ public class GraphSpec {
         public String id;
 
         /** 节点显示名称 */
-        @ToolParam(name = "name", description = "节点显示名称(人名 / 案件名 / 账户名等)")
+        @ToolParam(name = "name", description = "节点显示名称,同时是查询该节点明细的关键字:"
+                + "type=person 填人名、type=phone 填号码、type=card 填卡号(更名如「张三的卡」会查不到明细)")
         public String name;
 
+        /**
+         * 节点实体类型。
+         *
+         * <p>取值 person / phone / card / other,决定用户点击该节点时前端按哪个字段查明细:
+         * 人用 personName,电话号码用 personPhones,银行卡号用 personCardNo,其他没有明细。
+         * 与 {@link com.zsjz.ai.common.enums.GraphEdgeType} 一样,中文与旧写法会被归一化成 code;
+         * 缺失时按 person 处理。
+         */
+        @ToolParam(name = "type",
+                   description = "节点实体类型,只能取 person / phone / card / other:"
+                           + "person=人(姓名,查明细用 personName)、"
+                           + "phone=电话号码(查通话明细用 personPhones)、"
+                           + "card=银行卡号/账号(查交易明细用 personCardNo)、"
+                           + "other=其他实体(案件、公司、地址等,没有通话/交易明细)。"
+                           + "★ 必须按节点的真实实体填:节点值是人名就填 person,是手机号就填 phone,"
+                           + "是银行卡号就填 card,不要一律填 person,否则点击节点查不到明细。"
+                           + "省略时按 person 处理",
+                   required = false)
+        public String type;
+
         /** 分类名 */
         @ToolParam(name = "category", description = "分类名,用于分区着色,如「核心人员」「关联人员」", required = false)
         public String category;

+ 56 - 4
ai-server/src/test/java/com/zsjz/ai/module/agent/tools/GraphRenderToolTest.java

@@ -112,6 +112,48 @@ class GraphRenderToolTest {
         assertTrue(text(result).contains("type 非法"), text(result));
     }
 
+    @Test
+    @DisplayName("节点 type 缺失时按节点值兜底:手机号→phone、长数字→card、其余→person")
+    void infersNodeTypeWhenMissing() {
+        GraphSpec spec = spec("call");
+        spec.nodes = new ArrayList<>(List.of(
+                node("a", "张三"),
+                node("b", "13800138000"),
+                node("c", "6222020200112233445")));
+        String result = text(tool.renderGraph(spec));
+        assertTrue(result.contains("\"type\":\"person\""), result);
+        assertTrue(result.contains("\"type\":\"phone\""), result);
+        assertTrue(result.contains("\"type\":\"card\""), result);
+    }
+
+    @Test
+    @DisplayName("节点 type 的中文与旧写法(人 / obj / tel / 银行卡号)会被规范化成 code")
+    void normalizesNodeTypeAliases() {
+        GraphSpec spec = spec("call");
+        spec.nodes = new ArrayList<>(List.of(
+                node("a", "张三"),
+                node("b", "13800138000"),
+                node("c", "6222020200112233445")));
+        spec.nodes.get(0).type = "人";
+        spec.nodes.get(1).type = "tel";
+        spec.nodes.get(2).type = " 银行卡号 ";
+        String result = text(tool.renderGraph(spec));
+        assertTrue(result.contains("\"type\":\"person\""), result);
+        assertTrue(result.contains("\"type\":\"phone\""), result);
+        assertTrue(result.contains("\"type\":\"card\""), result);
+    }
+
+    @Test
+    @DisplayName("节点 type 非法必须被拒绝,并在报错里列出允许取值")
+    void rejectsUnknownNodeType() {
+        GraphSpec spec = spec("call");
+        spec.nodes.get(0).type = "公司";
+        ToolResultBlock result = tool.renderGraph(spec);
+        assertEquals(ToolResultState.ERROR, result.getState(), text(result));
+        assertTrue(text(result).contains("节点 a 的 type 非法"), text(result));
+        assertTrue(text(result).contains("person(人)"), text(result));
+    }
+
     @Test
     @DisplayName("原有的引用完整性校验不能被破坏")
     void keepsReferenceValidation() {
@@ -190,15 +232,16 @@ class GraphRenderToolTest {
     }
 
     @Test
-    @DisplayName("空字段被 NON_NULL 省略,数字 value 保持数字且不补 .0")
+    @DisplayName("空字段被 NON_NULL 省略,整数 value 保持整数形态(不补 .0)")
     void omitsNullsAndKeepsNumbers() {
         GraphSpec spec = spec("call");
         spec.nodes.get(0).value = 12;
-        spec.edges.get(0).value = 3.5;
+        // GraphSpec 的 value 是 Integer 契约(权重/次数),这里不能再赋小数
+        spec.edges.get(0).value = 35;
         String result = text(tool.renderGraph(spec));
-        // Number 而不是 Double:整数就是 12,不会变成 12.0
+        // 整数就是 12,不会序列化成 12.0
         assertTrue(result.contains("\"value\":12}"), result);
-        assertTrue(result.contains("\"value\":3.5}"), result);
+        assertTrue(result.contains("\"value\":35}"), result);
         // 未赋值的可选字段不应出现 "desc":null 之类噪声
         assertFalse(result.contains("null"), result);
     }
@@ -236,6 +279,15 @@ class GraphRenderToolTest {
                 .forEach(n -> nodeRequired.add(n.asText()));
         assertEquals(List.of("id", "name"), nodeRequired);
 
+        // 节点 type 可选,是 string,描述里写清四个取值
+        // (决定用户点击该节点时前端按 personName / personPhones / personCardNo 哪个字段查明细)
+        JsonNode nodeTypeSchema = graph.path("properties").path("nodes").path("items")
+                .path("properties").path("type");
+        assertEquals("string", nodeTypeSchema.path("type").asText());
+        assertTrue(nodeTypeSchema.path("description").asText().contains("person / phone / card / other"),
+                nodeTypeSchema.toString());
+        assertFalse(nodeRequired.contains("type"), nodeRequired.toString());
+
         // 边必填 source / target / type
         JsonNode edge = graph.path("properties").path("edges").path("items");
         List<String> edgeRequired = new ArrayList<>();