浏览代码

fix: 关系图谱前端直接取全量原始数据,避免模型裁剪丢关系

- get_case_graph 的工具结果仍只保留前 500 条边给模型做疑点分析,避免撑爆模型上下文。- 前端从工具入参解析同一组 type/personNames/日期/金额/次数,直接请求 /graph/view 取全量 nodeList/edgeList 渲染;展示数据不再来自模型输出,也不使用模型过滤后的内容。- 新增全量加载中/加载失败回退提示;历史消息缺少工具入参时仍回退到工具结果。- 同步修改工具描述与裁剪注释,明确 500 条只限制模型上下文,前端展示全量。
cc 2 天之前
父节点
当前提交
8d0899d784

+ 1 - 0
ai-electron/frontend/src/ai/components/ChatMessageItem.vue

@@ -75,6 +75,7 @@
               v-for="(item, index) in graphItems"
               :key="item.key"
               :payload="item.payload"
+              :query="item.query"
               :meta="item.meta"
               :index="index"
               :total="graphItems.length"

+ 69 - 4
ai-electron/frontend/src/ai/components/graph/GraphAnalysisBlock.vue

@@ -8,12 +8,22 @@
       <span v-if="model" class="ai-graph__count">节点 {{ model.nodes.length }} · 关系 {{ model.edges.length }}</span>
     </header>
 
-    <div v-if="model?.truncated" class="ai-graph__notice" :title="model.note">
+    <div v-if="fullGraphError" class="ai-graph__notice" :title="fullGraphError">
+      全量图谱加载失败,当前展示工具摘要(可能不完整):{{ fullGraphError }}
+    </div>
+    <div v-else-if="model?.truncated && !remotePayload" class="ai-graph__notice" :title="model.note">
       {{ model.note || `关系过多,仅展示部分数据(共 ${model.totalEdges ?? '?'} 条)` }}
     </div>
 
     <div ref="bodyRef" class="ai-graph__body">
-      <template v-if="model && renderable">
+      <div v-if="fullGraphLoading" class="ai-graph__placeholder">
+        <div class="ai-skeleton" :style="{ height: '18px', width: '38%' }"></div>
+        <div class="ai-skeleton" :style="{ height: '14px', width: '62%' }"></div>
+        <div class="ai-skeleton" :style="{ height: '14px', width: '48%' }"></div>
+        <span class="ai-graph__loading-text">正在加载全量图谱…</span>
+      </div>
+
+      <template v-else-if="model && renderable">
         <AsyncGraphViewer v-if="visible" ref="viewerRef" :model="model" @toggle-fullscreen="toggleFullscreen" />
         <div v-else class="ai-graph__placeholder">
           <div class="ai-skeleton" :style="{ height: '18px', width: '38%' }"></div>
@@ -42,16 +52,19 @@
    * 本组件只负责聊天里的容器与懒加载;画布/查找/过滤/明细在独立的
    * GraphAnalysisViewer 中实现,不与对象关系分析页面共用组件。
    */
-  import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
+  import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useBlockFullscreen } from '../../utils/useBlockFullscreen';
   import { parseGraphPayload } from './graphData';
+  import { fetchFullGraph } from './graphFullApi';
 
   const AsyncGraphViewer = defineAsyncComponent(() => import('./GraphAnalysisViewer.vue'));
 
   const props = withDefaults(
     defineProps<{
       payload: unknown;
+      /** get_case_graph 的原始查询参数:用于直接取全量图谱,展示不经过模型裁剪 */
+      query?: Record<string, unknown>;
       /** 查询摘要(人名 / 日期 / 口径) */
       meta?: string;
       /** 多张图时的序号(从 0 开始) */
@@ -65,8 +78,12 @@
   const rootRef = ref<HTMLElement>();
   const bodyRef = ref<HTMLElement>();
   const viewerRef = ref<any>(null);
+  const remotePayload = ref<unknown>(null);
+  const fullGraphLoading = ref(false);
+  const fullGraphError = ref('');
 
-  const model = computed(() => parseGraphPayload(props.payload));
+  const effectivePayload = computed(() => remotePayload.value ?? props.payload);
+  const model = computed(() => parseGraphPayload(effectivePayload.value));
   const renderable = computed(() => Boolean(model.value && model.value.nodes.length && model.value.edges.length));
   const metaText = computed(() => props.meta || '');
 
@@ -77,6 +94,30 @@
     return '暂无可展示的图谱数据';
   });
 
+  let fullGraphRequestToken = 0;
+
+  /**
+   * 工具结果为了模型上下文只带前 500 条边;这里按工具入参直接请求 /graph/view,
+   * 前端展示唯一数据源 = 后端原始全量图谱,不经过模型输出或模型过滤。
+   */
+  async function loadFullGraph() {
+    if (!props.query) return;
+    const token = ++fullGraphRequestToken;
+    fullGraphLoading.value = true;
+    fullGraphError.value = '';
+    try {
+      const payload = await fetchFullGraph(props.query);
+      if (token !== fullGraphRequestToken) return;
+      remotePayload.value = payload;
+    } catch (error: any) {
+      if (token !== fullGraphRequestToken) return;
+      remotePayload.value = null;
+      fullGraphError.value = error?.message || '请求失败';
+    } finally {
+      if (token === fullGraphRequestToken) fullGraphLoading.value = false;
+    }
+  }
+
   /* ---------------- 懒加载:首次进入视口才挂载 G6 ---------------- */
 
   const visible = ref(false);
@@ -104,6 +145,24 @@
     observer = null;
   });
 
+  watch(
+    () => visible.value,
+    () => {
+      if (visible.value) void loadFullGraph();
+    },
+    { immediate: true },
+  );
+
+  watch(
+    () => props.query,
+    () => {
+      remotePayload.value = null;
+      fullGraphError.value = '';
+      if (visible.value) void loadFullGraph();
+    },
+    { deep: true },
+  );
+
   /* ---------------- 全屏 ---------------- */
 
   const { isFullscreen, toggle: toggleFullscreen } = useBlockFullscreen(async () => {
@@ -236,6 +295,12 @@
       }
     }
 
+    &__loading-text {
+      margin-top: 2px;
+      font-size: 12px;
+      color: var(--ai-text-2, #64748b);
+    }
+
     &__empty {
       display: flex;
       align-items: center;

+ 94 - 0
ai-electron/frontend/src/ai/components/graph/graphFullApi.ts

@@ -0,0 +1,94 @@
+/**
+ * AI 聊天关系图谱 —— 全量图谱取数
+ *
+ * get_case_graph 的工具结果会为了模型上下文做裁剪(只保留前 500 条边)。
+ * 前端展示必须使用完整原始数据,因此这里按工具入参(type/personNames/日期/金额/次数)
+ * 直接请求原对象关系分析接口 /graph/view,不经过模型,也不使用模型输出。
+ */
+import { defHttp } from '@/core/utils/http/axios';
+import { useGlobSetting } from '@/core/hooks/setting';
+
+const { adminPath } = useGlobSetting();
+
+const QUERY_KEYS = [
+  'type',
+  'personNames',
+  'personName',
+  'startDate',
+  'endDate',
+  'startMoney',
+  'endMoney',
+  'callNum',
+  'cards',
+] as const;
+
+function toPositiveNumbers(value: unknown): number[] {
+  const list = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
+  return list.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0);
+}
+
+function toStringList(value: unknown): string[] {
+  const list = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
+  return list.map((item) => String(item ?? '').trim()).filter(Boolean);
+}
+
+/** 把工具入参清洗成 /graph/view 需要的 GraphQuery(只保留真正参与查询的字段) */
+export function normalizeFullGraphQuery(raw: Record<string, unknown>): Record<string, unknown> | null {
+  const source = raw.query && typeof raw.query === 'object' ? (raw.query as Record<string, unknown>) : raw;
+  const query: Record<string, unknown> = {};
+
+  QUERY_KEYS.forEach((key) => {
+    const value = source[key];
+    if (value === undefined || value === null || value === '') return;
+    if (key === 'type') {
+      const types = toPositiveNumbers(value);
+      if (types.length) query.type = types;
+      return;
+    }
+    if (key === 'personNames') {
+      const names = toStringList(value);
+      if (names.length) query.personNames = names;
+      return;
+    }
+    if (key === 'cards') {
+      const cards = toStringList(value);
+      if (cards.length) query.cards = cards;
+      return;
+    }
+    if (key === 'startMoney' || key === 'endMoney' || key === 'callNum') {
+      query[key] = String(value).trim();
+      return;
+    }
+    query[key] = typeof value === 'string' ? value.trim() : value;
+  });
+
+  if (!Array.isArray(query.type) || !query.type.length) return null;
+  if (!Array.isArray(query.personNames) || !query.personNames.length) return null;
+  return query;
+}
+
+/** 直接请求 /graph/view,返回完整 nodeList/edgeList(最多后端 1 万条边上限) */
+export async function fetchFullGraph(raw: Record<string, unknown>): Promise<Record<string, unknown>> {
+  const query = normalizeFullGraphQuery(raw);
+  if (!query) throw new Error('关系图谱查询参数不完整');
+
+  const response = await defHttp.postJson<any>(
+    {
+      url: adminPath + '/graph/view',
+      data: query,
+    },
+    { skipSuccessMessage: true },
+  );
+
+  // 兼容 defHttp 已解包 data / 未解包 Result 两种情况
+  const payload = response?.data !== undefined ? response.data : response;
+  const graphData = payload?.data !== undefined ? payload.data : payload;
+  if (
+    !graphData ||
+    typeof graphData !== 'object' ||
+    (!Array.isArray((graphData as any).nodeList) && !Array.isArray((graphData as any).edgeList))
+  ) {
+    throw new Error('全量图谱返回格式不正确');
+  }
+  return graphData as Record<string, unknown>;
+}

+ 12 - 0
ai-electron/frontend/src/ai/utils/graphToolData.ts

@@ -10,6 +10,8 @@ import { parseJsonPayload } from './parseMessageContent';
 export interface GraphToolItem {
   key: string;
   payload: unknown;
+  /** get_case_graph 的原始查询参数,前端据此直接向 /graph/view 取全量图谱 */
+  query?: Record<string, unknown>;
   meta: string;
 }
 
@@ -51,9 +53,19 @@ export function parseGraphToolSteps(
     if (toolName === 'render_graph' && !includeLegacy) return;
     const payload = parseJsonPayload(step.result);
     if (!looksLikeGraphPayload(payload)) return;
+    const input = parseJsonPayload(step.input);
+    const nestedQuery =
+      toolName === 'get_case_graph' && input && typeof input === 'object' && !Array.isArray(input)
+        ? ((input as Record<string, unknown>).query ?? input)
+        : null;
+    const query =
+      nestedQuery && typeof nestedQuery === 'object' && !Array.isArray(nestedQuery)
+        ? (nestedQuery as Record<string, unknown>)
+        : undefined;
     result.push({
       key: String(step.id || `graph-${index}`),
       payload,
+      query,
       meta: describeGraphQuery(step.input),
     });
   });

+ 9 - 7
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GraphAnalysisTool.java

@@ -78,8 +78,9 @@ public class GraphAnalysisTool {
      * 进模型上下文的关系边上限。
      *
      * <p>{@code GraphService#graph} 只保证「不超过 1 万条边」,1 万条边的 JSON 约 1MB、
-     * 折合 30 万 token,直接塞给模型会爆上下文。而前端关系图渲染上限同为
-     * 500 条边,因此这里按权重降序保留前 500 条并明示截断,与渲染上限对齐。
+     * 折合 30 万 token,直接塞给模型会爆上下文。因此这里按权重降序保留前 500 条给模型
+     * 做疑点分析;<b>前端展示不使用这份裁剪结果</b>,会按工具入参直接请求
+     * {@code /graph/view} 取全量原始图谱(见前端 graphFullApi.ts),避免丢关系。
      */
     private static final int MAX_EDGES_IN_CONTEXT = 500;
 
@@ -109,8 +110,9 @@ public class GraphAnalysisTool {
                   + "\"num\":关系权重(金额或次数,字符串),\"fx\":方向(1/2),\"lx\":对方类型(1卡2人3其他4无)}]}。"
                   + "edgeList 为空表示这些人员之间没有符合条件的关系。"
                   + "startDate/endDate 必须成对传才生效。"
-                  + "结果可能较大(服务端上限 1 万条边);超过 500 条边时返回体自动截断为权重最高的 500 条,"
-                  + "并带 truncated=true / totalEdges / note 字段,此时若要看全量请缩小日期范围或减少人员。",
+                  + "结果可能较大(服务端上限 1 万条边);超过 500 条边时模型上下文会按权重保留前 500 条,"
+                  + "并带 truncated=true / totalEdges / note 字段,仅用于你的疑点分析;"
+                  + "前端会按同一查询直接取全量原始图谱展示,不受这份裁剪影响。",
           readOnly = true)
     public ToolResultBlock getCaseGraph(
             @ToolParam(name = "query",
@@ -189,9 +191,9 @@ public class GraphAnalysisTool {
         trimmed.put("totalEdges", edges.size());
         trimmed.put("totalNodes", nodes.size());
         trimmed.put("truncated", true);
-        trimmed.put("note", "关系边过多,已按权重(num)降序保留前 " + MAX_EDGES_IN_CONTEXT
-                + " 条,共 " + edges.size() + " 条;前端关系图渲染上限同为 " + MAX_EDGES_IN_CONTEXT
-                + " 条。若要看全量关系,请缩小日期范围、减少人员,或提高金额/通话次数阈值后重查。");
+        trimmed.put("note", "关系边过多,模型上下文已按权重(num)降序保留前 " + MAX_EDGES_IN_CONTEXT
+                + " 条,共 " + edges.size() + " 条;前端会按同一查询直接取全量原始图谱展示,"
+                + "不受此裁剪影响。若模型需要看更多关系,请缩小日期范围或减少人员后重查。");
         trimmed.put("nodeList", keptNodes);
         trimmed.put("edgeList", keptEdges);
         return trimmed;