Ver código fonte

fix: 关系图谱全量数据改由后端 graph_data 帧下发,移除前端补边与直连

- 后端在 get_case_graph 工具结果之后,按同一入参和案件上下文重新调用 GraphService 取全量 nodeList/edgeList,通过独立 graph_data SSE 帧下发;工具结果仍只保留前 500 条供模型分析。- 前端新增 graph_data 事件处理,把全量图谱挂到对应 ToolStep;图谱块只渲染后端返回数据,不再请求 /graph/view,也不再按 nodecn 补造人员归属边。- 删除 graphFullApi.ts 及前端补边逻辑;历史消息缺少 graph_data 时仍回退到工具结果。
cc 3 dias atrás
pai
commit
1cab4027a6

+ 3 - 0
ai-electron/frontend/src/ai/api/sse.ts

@@ -79,6 +79,9 @@ export function createParser(handlers: SseHandlers): SseFrameParser {
       case 'heartbeat':
         handlers.onHeartbeat?.(data as any);
         break;
+      case 'graph_data':
+        handlers.onGraphData?.(data as any);
+        break;
       case 'insight':
         handlers.onInsight?.(data as any);
         break;

+ 16 - 0
ai-electron/frontend/src/ai/api/types.ts

@@ -129,6 +129,8 @@ export interface ToolStep {
   input?: string;
   /** 工具结果(字符串;execute_sql 为完整 JSON 文本) */
   result?: string;
+  /** 后端 graph_data 帧下发的全量关系图谱(仅 get_case_graph,不进入模型上下文) */
+  graphPayload?: unknown;
   status?: 'running' | 'done' | 'error';
 }
 
@@ -457,6 +459,17 @@ export interface SseHeartbeatData {
   ts?: number;
 }
 
+/** 全量关系图谱帧(后端在 get_case_graph 工具结果之后下发,不进入模型上下文) */
+export interface SseGraphDataData {
+  type: 'graph_data';
+  toolCallId: string;
+  graph: {
+    nodeList?: unknown[];
+    edgeList?: unknown[];
+    [key: string]: unknown;
+  };
+}
+
 /** 结束帧 */
 export interface SseDoneData {
   type: 'done';
@@ -483,6 +496,7 @@ export type SseEventData =
   | SseToolInputData
   | SseToolResultData
   | SseHeartbeatData
+  | SseGraphDataData
   | SseInsightData
   | SseTokenData
   | SseDoneData
@@ -498,6 +512,8 @@ export interface SseHandlers {
   onToolResult?: (data: SseToolResultData) => void;
   /** SSE 心跳(不产生正文/过程内容,仅用于连接存活判断) */
   onHeartbeat?: (data: SseHeartbeatData) => void;
+  /** 全量关系图谱(仅 get_case_graph,工具结果之后单独下发) */
+  onGraphData?: (data: SseGraphDataData) => void;
   /** 研判结构化产物(仅 /insight/continuous/stream 会触发) */
   onInsight?: (data: SseInsightData) => void;
   onToken?: (data: SseTokenData) => void;

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

@@ -68,7 +68,6 @@
             :key="`graph-${item.step.id}`"
             class="ai-trace__graph"
             :payload="graphOf(item.step)!.payload"
-            :query="graphOf(item.step)!.query"
             :meta="graphOf(item.step)!.meta"
           />
         </template>

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

@@ -8,22 +8,12 @@
       <span v-if="model" class="ai-graph__count">节点 {{ model.nodes.length }} · 关系 {{ model.edges.length }}</span>
     </header>
 
-    <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">
+    <div v-if="model?.truncated" class="ai-graph__notice" :title="model.note">
       {{ model.note || `关系过多,仅展示部分数据(共 ${model.totalEdges ?? '?'} 条)` }}
     </div>
 
     <div ref="bodyRef" class="ai-graph__body">
-      <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">
+      <template v-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>
@@ -52,19 +42,16 @@
    * 本组件只负责聊天里的容器与懒加载;画布/查找/过滤/明细在独立的
    * GraphAnalysisViewer 中实现,不与对象关系分析页面共用组件。
    */
-  import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+  import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref } 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 开始) */
@@ -78,12 +65,8 @@
   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 effectivePayload = computed(() => remotePayload.value ?? props.payload);
-  const model = computed(() => parseGraphPayload(effectivePayload.value));
+  const model = computed(() => parseGraphPayload(props.payload));
   const renderable = computed(() => Boolean(model.value && model.value.nodes.length && model.value.edges.length));
   const metaText = computed(() => props.meta || '');
 
@@ -94,30 +77,6 @@
     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);
@@ -145,24 +104,6 @@
     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 () => {

+ 0 - 62
ai-electron/frontend/src/ai/components/graph/graphData.ts

@@ -407,68 +407,6 @@ export function parseGraphPayload(raw: unknown): ParsedGraph | null {
     if (edge) edges.push(edge);
   });
 
-  /**
-   * 人员归属补边兜底:
-   * 后端的 obj_card / obj_tel(持卡人 / 机主)关系有时不会进入 edgeList,
-   * 导致人员节点成为孤立点。这里只对「完全没有边」的人员节点,按节点原始
-   * nodecn/mc/name 与人员姓名匹配,补一条人员 → 银行卡/手机号的展示边;
-   * 已有关系的节点不会重复补,也不改变任何原始字段。
-   */
-  const nodesWithEdge = new Set<string>();
-  edges.forEach((edge) => {
-    nodesWithEdge.add(edge.source);
-    nodesWithEdge.add(edge.target);
-  });
-  const normalizeNames = (values: unknown[]) => new Set(values.map((value) => normalizeToken(value)).filter(Boolean));
-  const personNodes = Array.from(ctx.nodeMap.values()).filter(
-    (node) => node.kind === 'person' && !nodesWithEdge.has(node.id),
-  );
-  personNodes.forEach((person) => {
-    const personNames = normalizeNames([
-      person.label,
-      person.fullLabel,
-      person.secondaryLabel,
-      person.raw.nodecn,
-      person.raw.mc,
-      person.raw.name,
-    ]);
-    if (!personNames.size) return;
-    const assets = Array.from(ctx.nodeMap.values()).filter((node) => {
-      if (node.id === person.id || !['bank', 'phone'].includes(node.kind)) return false;
-      const assetNames = normalizeNames([
-        node.raw.nodecn,
-        node.raw.mc,
-        node.raw.name,
-        node.raw.mc1,
-        node.secondaryLabel,
-        node.label,
-      ]);
-      return Array.from(personNames).some((name) => assetNames.has(name));
-    });
-    assets.forEach((asset) => {
-      const edgeId = `person-asset-${person.id}-${asset.id}`;
-      if (usedEdgeIds.has(edgeId)) return;
-      usedEdgeIds.add(edgeId);
-      edges.push({
-        id: edgeId,
-        source: person.id,
-        target: asset.id,
-        clazz: asset.kind === 'phone' ? 'obj_tel' : 'obj_card',
-        label: asset.kind === 'phone' ? '机主' : '持卡人',
-        raw: {
-          node1: person.raw.node || person.label,
-          node2: asset.raw.node || asset.label,
-          node1cn: person.label,
-          node2cn: asset.raw.nodecn || asset.label,
-          mc: asset.kind === 'phone' ? '机主' : '持卡人',
-          clazz: asset.kind === 'phone' ? 'obj_tel' : 'obj_card',
-          num: '1',
-          synthetic: true,
-        },
-      });
-    });
-  });
-
   const nodes = Array.from(ctx.nodeMap.values());
   const totalEdges = toFiniteNumber(payload.totalEdges);
   const totalNodes = toFiniteNumber(payload.totalNodes);

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

@@ -1,94 +0,0 @@
-/**
- * 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>;
-}

+ 5 - 0
ai-electron/frontend/src/ai/store/chatStream.ts

@@ -456,6 +456,11 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
             onHeartbeat: () => {
               markHeartbeat();
             },
+            onGraphData: (data) => {
+              markActivity('graph_data');
+              const step = target.toolSteps?.find((s) => s.id === data.toolCallId);
+              if (step) step.graphPayload = data.graph;
+            },
             onDone: () => {
               doneReceived = true;
               markActivity('done');

+ 3 - 25
ai-electron/frontend/src/ai/utils/graphToolData.ts

@@ -10,8 +10,6 @@ import { parseJsonPayload } from './parseMessageContent';
 export interface GraphToolItem {
   key: string;
   payload: unknown;
-  /** get_case_graph 的原始查询参数,前端据此直接向 /graph/view 取全量图谱 */
-  query?: Record<string, unknown>;
   meta: string;
 }
 
@@ -36,24 +34,14 @@ function looksLikeGraphPayload(value: any): boolean {
   );
 }
 
-/** 解析单个工具步骤的图谱结果;仅 get_case_graph 走全量数据链路 */
+/** 解析单个工具步骤的图谱结果;优先使用后端 graph_data 帧的全量图谱 */
 export function parseGraphToolStep(step?: ToolStep | null): GraphToolItem | null {
   if (!step || step.status === 'error' || String(step.toolName || '') !== 'get_case_graph') return null;
-  const payload = parseJsonPayload(step.result);
+  const payload = step.graphPayload ?? parseJsonPayload(step.result);
   if (!looksLikeGraphPayload(payload)) return null;
-  const input = parseJsonPayload(step.input);
-  const nestedQuery =
-    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;
   return {
     key: String(step.id || 'graph'),
     payload,
-    query,
     meta: describeGraphQuery(step.input),
   };
 }
@@ -73,21 +61,11 @@ export function parseGraphToolSteps(
     if (!GRAPH_TOOL_NAMES.has(toolName)) return;
     // 旧消息通常同时有 render_graph 工具步骤和 ```graph 围栏;有围栏时只渲染围栏,避免重复出图
     if (toolName === 'render_graph' && !includeLegacy) return;
-    const payload = parseJsonPayload(step.result);
+    const payload = step.graphPayload ?? 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),
     });
   });

+ 92 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java

@@ -25,6 +25,8 @@ import com.zsjz.ai.module.agent.tools.ToolResultLimits;
 import com.zsjz.ai.module.agent.usage.UsageStore;
 import com.zsjz.ai.module.agent.vo.ChatSessionVO;
 import com.zsjz.ai.module.agent.vo.MessageVO;
+import com.zsjz.ai.module.graph.service.GraphService;
+import com.zsjz.ai.common.model.graph.query.GraphQuery;
 import io.agentscope.core.agent.RuntimeContext;
 import io.agentscope.core.event.*;
 import io.agentscope.core.message.Msg;
@@ -42,6 +44,7 @@ import reactor.core.publisher.Sinks;
 import reactor.core.scheduler.Schedulers;
 
 import java.time.Duration;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.*;
 import java.util.concurrent.ConcurrentHashMap;
@@ -94,6 +97,7 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
     private final AgentCapabilityService agentCapabilityService;
     private final UsageStore usageStore;
     private final com.zsjz.ai.module.agent.attachment.ChatAttachmentService chatAttachmentService;
+    private final GraphService graphService;
     private static final ObjectMapper MAPPER = new ObjectMapper();
 
     public AgentChatServiceImpl(UsageStore usageStore,
@@ -102,7 +106,8 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
                                 AgentCapabilityService agentCapabilityService,
                                 AgentMessageMapper messageMapper,
                                 AgentChatSessionMapper chatSessionMapper,
-                                com.zsjz.ai.module.agent.attachment.ChatAttachmentService chatAttachmentService) {
+                                com.zsjz.ai.module.agent.attachment.ChatAttachmentService chatAttachmentService,
+                                GraphService graphService) {
         this.usageStore = usageStore;
         this.agentService = agentService;
         this.agentModelService = agentModelService;
@@ -110,6 +115,7 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
         this.messageMapper = messageMapper;
         this.chatSessionMapper = chatSessionMapper;
         this.chatAttachmentService = chatAttachmentService;
+        this.graphService = graphService;
     }
 
     @Override
@@ -358,6 +364,10 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
 
     private Flux<ServerSentEvent<String>> doStreamLocked(Long sessionId, ChatRequestDTO req, StreamProcess process) {
         AgentChatSession session = requireSessionInOpenCase(requireSession(sessionId));
+        // 本轮流的案件上下文:get_case_graph 的全量图谱补取要用它显式绑定,
+        // 因为 GraphService 的 @DS 路由只认 CaseContextHolder。
+        Long graphCaseId = session.getCaseId();
+        Long graphUserId = CaseContextHolder.getUserId();
 
         // 附件:归属校验 + 注入块(内联全文 / 表格 Python 直读指引 / RAG 片段)。
         // 草稿期上传的附件(sessionId=0)在此绑定到当前会话;越权/失败直接 400 不进流
@@ -478,6 +488,22 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
                     data.put("toolResult", text.length() > maxChars ? text.substring(0, maxChars) : text);
                 }
                 frames.add(sse("tool_result", data));
+
+                // 前端关系图必须用后端原始全量数据:get_case_graph 的工具结果会为模型上下文
+                // 裁剪到前 500 条边,这里在同一案件上下文里重新取一次全量图谱,作为独立
+                // graph_data 帧下发给前端(不进入模型上下文,也不经过模型过滤)。
+                if ("get_case_graph".equals(e.getToolCallName())) {
+                    StringBuilder input = toolInputs.get(e.getToolCallId());
+                    Map<String, Object> fullGraph = buildFullGraphPayload(
+                            input == null ? null : input.toString(), graphCaseId, graphUserId);
+                    if (fullGraph != null) {
+                        Map<String, Object> graphFrame = new LinkedHashMap<>();
+                        graphFrame.put("type", "graph_data");
+                        graphFrame.put("toolCallId", e.getToolCallId());
+                        graphFrame.put("graph", fullGraph);
+                        frames.add(sse("graph_data", graphFrame));
+                    }
+                }
             } else if (event instanceof TextBlockDeltaEvent e) {
                 accumulatedText.get().append(e.getDelta());
                 frames.add(sse("token", Map.of("type", "token", "data", e.getDelta())));
@@ -898,6 +924,71 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
         return vo;
     }
 
+    /**
+     * 按 get_case_graph 的原始入参重新取一次全量图谱,供前端 graph_data 帧使用。
+     *
+     * <p>工具结果本身为了模型上下文只保留前 500 条边,不能拿它当展示数据;
+     * 这里在 chat 流自己的案件上下文里直接调用 GraphService,拿到完整 nodeList/edgeList。
+     */
+    private Map<String, Object> buildFullGraphPayload(String toolInput, Long caseId, Long userId) {
+        if (!StringUtils.hasText(toolInput)) {
+            return null;
+        }
+        try {
+            JsonNode root = MAPPER.readTree(unwrapJsonString(toolInput));
+            JsonNode query = root.path("query");
+            if (!query.isObject()) {
+                query = root;
+            }
+            JsonNode typeNode = query.path("type");
+            JsonNode nameNode = query.path("personNames");
+            if (!typeNode.isArray() || !nameNode.isArray()) {
+                return null;
+            }
+            List<Integer> types = new ArrayList<>();
+            typeNode.forEach(node -> {
+                if (node.isNumber()) {
+                    types.add(node.asInt());
+                }
+            });
+            List<String> personNames = new ArrayList<>();
+            nameNode.forEach(node -> {
+                String name = node.asText("").trim();
+                if (!name.isEmpty()) {
+                    personNames.add(name);
+                }
+            });
+            if (types.isEmpty() || personNames.isEmpty()) {
+                return null;
+            }
+            GraphQuery graphQuery = new GraphQuery();
+            graphQuery.setType(types);
+            graphQuery.setPersonNames(personNames);
+            String startDate = jsonText(query.get("startDate"));
+            String endDate = jsonText(query.get("endDate"));
+            if (startDate != null) graphQuery.setStartDate(LocalDate.parse(startDate));
+            if (endDate != null) graphQuery.setEndDate(LocalDate.parse(endDate));
+            String startMoney = jsonText(query.get("startMoney"));
+            String endMoney = jsonText(query.get("endMoney"));
+            String callNum = jsonText(query.get("callNum"));
+            if (startMoney != null) graphQuery.setStartMoney(startMoney);
+            if (endMoney != null) graphQuery.setEndMoney(endMoney);
+            if (callNum != null) graphQuery.setCallNum(callNum);
+            return CaseContextHolder.callWith(caseId, userId, () -> graphService.graph(graphQuery));
+        } catch (Exception e) {
+            log.warn("构建全量关系图失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private static String jsonText(JsonNode node) {
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        String text = node.asText("").trim();
+        return text.isEmpty() ? null : text;
+    }
+
     private ServerSentEvent<String> sse(String eventType, Object data) {
         String json;
         try {

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

@@ -79,8 +79,8 @@ public class GraphAnalysisTool {
      *
      * <p>{@code GraphService#graph} 只保证「不超过 1 万条边」,1 万条边的 JSON 约 1MB、
      * 折合 30 万 token,直接塞给模型会爆上下文。因此这里按权重降序保留前 500 条给模型
-     * 做疑点分析;<b>前端展示不使用这份裁剪结果</b>,会按工具入参直接请求
-     * {@code /graph/view} 取全量原始图谱(见前端 graphFullApi.ts),避免丢关系。
+     * 做疑点分析;<b>前端展示不使用这份裁剪结果</b>,后端会在同一案件上下文内重新取全量图谱
+     * 并通过独立 {@code graph_data} SSE 帧下发前端,避免丢关系。
      */
     private static final int MAX_EDGES_IN_CONTEXT = 500;
 
@@ -112,7 +112,7 @@ public class GraphAnalysisTool {
                   + "startDate/endDate 必须成对传才生效。"
                   + "结果可能较大(服务端上限 1 万条边);超过 500 条边时模型上下文会按权重保留前 500 条,"
                   + "并带 truncated=true / totalEdges / note 字段,仅用于你的疑点分析;"
-                  + "前端会按同一查询直接取全量原始图谱展示,不受这份裁剪影响。",
+                  + "前端展示由后端 graph_data 帧下发全量原始图谱,不受这份裁剪影响。",
           readOnly = true)
     public ToolResultBlock getCaseGraph(
             @ToolParam(name = "query",
@@ -192,7 +192,7 @@ public class GraphAnalysisTool {
         trimmed.put("totalNodes", nodes.size());
         trimmed.put("truncated", true);
         trimmed.put("note", "关系边过多,模型上下文已按权重(num)降序保留前 " + MAX_EDGES_IN_CONTEXT
-                + " 条,共 " + edges.size() + " 条;前端会按同一查询直接取全量原始图谱展示,"
+                + " 条,共 " + edges.size() + " 条;前端展示由后端 graph_data 帧下发全量原始图谱,"
                 + "不受此裁剪影响。若模型需要看更多关系,请缩小日期范围或减少人员后重查。");
         trimmed.put("nodeList", keptNodes);
         trimmed.put("edgeList", keptEdges);