|
|
@@ -77,12 +77,6 @@ public class GraphAnalysisTool {
|
|
|
/** 图谱维度:全部通话 / 共同通话 / 相互通话 */
|
|
|
private static final List<Integer> CALL_TYPES = List.of(201, 202, 203);
|
|
|
|
|
|
- /** 节点类型:银行卡/账户 */
|
|
|
- private static final String CLAZZ_CARD = "card";
|
|
|
-
|
|
|
- /** 节点类型:手机号 */
|
|
|
- private static final String CLAZZ_TEL = "tel";
|
|
|
-
|
|
|
/**
|
|
|
* 进模型上下文的关系边上限。
|
|
|
*
|
|
|
@@ -93,11 +87,9 @@ public class GraphAnalysisTool {
|
|
|
private static final int MAX_EDGES_IN_CONTEXT = 500;
|
|
|
|
|
|
private final GraphService graphService;
|
|
|
- private final SqlResultStore sqlResultStore;
|
|
|
|
|
|
- public GraphAnalysisTool(GraphService graphService, SqlResultStore sqlResultStore) {
|
|
|
+ public GraphAnalysisTool(GraphService graphService) {
|
|
|
this.graphService = graphService;
|
|
|
- this.sqlResultStore = sqlResultStore;
|
|
|
}
|
|
|
|
|
|
// ==================== 工具定义 ====================
|
|
|
@@ -216,182 +208,9 @@ public class GraphAnalysisTool {
|
|
|
return 0d;
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 图谱资金边明细
|
|
|
- */
|
|
|
- @Tool(name = "get_graph_trans_detail",
|
|
|
- description = "查询关系图谱上一条资金边(clazz=card_card)的明细交易记录(一行一笔),"
|
|
|
- + "可按本方卡号/对方卡号/对方类型(lx)/对方名称/交易摘要/金额/日期筛选,结果以可翻页表格返回。"
|
|
|
- + "★ 用户点击图谱上的资金边看明细时用本工具,不要用 execute_sql 手写 SQL —— "
|
|
|
- + "收支方向(支出/收入)与 lx=2/3 时用 node1 推导对方姓名/摘要的口径已固化在服务端。"
|
|
|
- + "参数直接取该边上的字段:personCardNo=node1、otherCardNo=node2、lx=lx。"
|
|
|
- + "★ lx 为 2(对方是人)或 3(对方是其他)时必须传 personCardNo,否则服务端会执行失败。"
|
|
|
- + "不传任何筛选会命中全案交易并因超过行数上限被拒绝,请至少给出 personCardNo / otherCardNo / 日期范围之一。",
|
|
|
- readOnly = true)
|
|
|
- public ToolResultBlock getGraphTransDetail(
|
|
|
- @ToolParam(name = "query", description = "图谱资金边明细筛选条件,建议至少传一个定位参数")
|
|
|
- GraphToolSpecs.GraphTransDetailSpec query) {
|
|
|
- GraphToolSpecs.GraphTransDetailSpec spec =
|
|
|
- query == null ? new GraphToolSpecs.GraphTransDetailSpec() : query;
|
|
|
- try {
|
|
|
- String personCardNo = trimToNull(spec.personCardNo);
|
|
|
- Integer lx = spec.lx == null ? null : spec.lx.intValue();
|
|
|
- if (lx != null && (lx == 2 || lx == 3) && personCardNo == null) {
|
|
|
- return ToolResults.error("lx=" + lx + " 时必须传 personCardNo(服务端要用它推导对方姓名/交易摘要)");
|
|
|
- }
|
|
|
-
|
|
|
- GraphTransDetailQuery q = new GraphTransDetailQuery();
|
|
|
- q.setPersonCardNo(personCardNo);
|
|
|
- q.setOtherCardNo(trimToNull(spec.otherCardNo));
|
|
|
- q.setLx(lx);
|
|
|
- q.setOtherName(trimToNull(spec.otherName));
|
|
|
- q.setExt1(trimToNull(spec.ext1));
|
|
|
- if (spec.transAmount != null) {
|
|
|
- // 比较符与金额缺一不可:XML 里是 `${p.transAmountSymbol} #{p.transAmount}`,
|
|
|
- // 只给金额会拼出 `transAmount 1000` 这种非法 SQL
|
|
|
- q.setTransAmount(spec.transAmount);
|
|
|
- q.setTransAmountSymbol(symbolOrDefault(spec.transAmountSymbol, SymbolEnum.GE));
|
|
|
- }
|
|
|
- q.setStartDate(parseDate(spec.startDate, "startDate"));
|
|
|
- q.setEndDate(parseDate(spec.endDate, "endDate"));
|
|
|
- applyFetchWindow(q);
|
|
|
-
|
|
|
- Page<TransRecord> page = graphService.transDetail(q);
|
|
|
- return render(page, spec.pageSize);
|
|
|
- } catch (Exception e) {
|
|
|
- return fail("图谱资金边明细查询", e);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 图谱通话边明细
|
|
|
- */
|
|
|
- @Tool(name = "get_graph_call_detail",
|
|
|
- description = "查询关系图谱上一条通话边(clazz=tel_tel)的明细通话记录(一行一通电话),"
|
|
|
- + "可按本方号码/对方号码/日期筛选,结果以可翻页表格返回。"
|
|
|
- + "★ 用户点击图谱上的通话边看明细时用本工具。参数直接取该边上的字段:"
|
|
|
- + "personPhone=node1、otherPhone=node2。"
|
|
|
- + "该查询按两个方向合并(号码 A 打给 B、B 打给 A 都会命中),不区分主被叫。"
|
|
|
- + "建议至少传 personPhone 或 otherPhone 之一,否则会命中全案话单并因超过行数上限被拒绝。",
|
|
|
- readOnly = true)
|
|
|
- public ToolResultBlock getGraphCallDetail(
|
|
|
- @ToolParam(name = "query", description = "图谱通话边明细筛选条件,建议至少传一个号码")
|
|
|
- GraphToolSpecs.GraphCallDetailSpec query) {
|
|
|
- GraphToolSpecs.GraphCallDetailSpec spec =
|
|
|
- query == null ? new GraphToolSpecs.GraphCallDetailSpec() : query;
|
|
|
- try {
|
|
|
- GraphCallDetailQuery q = new GraphCallDetailQuery();
|
|
|
- q.setPersonPhone(trimToNull(spec.personPhone));
|
|
|
- q.setOtherPhone(trimToNull(spec.otherPhone));
|
|
|
- q.setStartDate(parseDate(spec.startDate, "startDate"));
|
|
|
- q.setEndDate(parseDate(spec.endDate, "endDate"));
|
|
|
- applyFetchWindow(q);
|
|
|
-
|
|
|
- Page<CallRecord> page = graphService.callDetail(q);
|
|
|
- return render(page, spec.pageSize);
|
|
|
- } catch (Exception e) {
|
|
|
- return fail("图谱通话边明细查询", e);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 图谱节点明细
|
|
|
- */
|
|
|
- @Tool(name = "get_graph_node_detail",
|
|
|
- description = "查询关系图谱上某个节点的明细:clazz=card 时按卡号查交易明细(该卡作为本方卡或对方卡都算),"
|
|
|
- + "clazz=tel 时按号码查通话明细(该号作为本方号码)。"
|
|
|
- + "★ 用户点击图谱节点看明细时用本工具,clazz 与 node 必传(取值来自 nodeList 的 clazz / node)。"
|
|
|
- + "返回 JSON 结构:{\"pages\":{\"records\":[明细行...],\"total\":总行数,...},\"head\":表头定义,\"detail\":false},"
|
|
|
- + "即 records 为该节点的明细行(最多 pageSize 行,默认 50),total 是总行数。"
|
|
|
- + "本工具返回的是原始分页 JSON,不是表格;若要可翻页的完整明细表,"
|
|
|
- + "请改用 get_graph_trans_detail(资金)/ get_graph_call_detail(通话)并传入该节点与对端节点。",
|
|
|
- readOnly = true)
|
|
|
- public ToolResultBlock getGraphNodeDetail(
|
|
|
- @ToolParam(name = "query", description = "图谱节点明细筛选条件,clazz 与 node 必传")
|
|
|
- GraphToolSpecs.GraphNodeDetailSpec query) {
|
|
|
- GraphToolSpecs.GraphNodeDetailSpec spec =
|
|
|
- query == null ? new GraphToolSpecs.GraphNodeDetailSpec() : query;
|
|
|
- try {
|
|
|
- String clazz = trimToNull(spec.clazz);
|
|
|
- if (clazz == null) {
|
|
|
- return ToolResults.error("clazz 必传:card(银行卡/账户节点)或 tel(手机号节点)");
|
|
|
- }
|
|
|
- if (!CLAZZ_CARD.equals(clazz) && !CLAZZ_TEL.equals(clazz)) {
|
|
|
- return ToolResults.error("clazz 只能取 card 或 tel,当前值:" + clazz);
|
|
|
- }
|
|
|
- String node = trimToNull(spec.node);
|
|
|
- if (node == null) {
|
|
|
- return ToolResults.error("node 必传:clazz=card 时传卡号,clazz=tel 时传手机号");
|
|
|
- }
|
|
|
-
|
|
|
- GraphCallDetailQuery q = new GraphCallDetailQuery();
|
|
|
- q.setClazz(clazz);
|
|
|
- q.setNode(node);
|
|
|
- if (spec.transAmount != null) {
|
|
|
- // 服务端用 getTransAmountSymbol() 取符号并拼进 SQL,比较符必须走白名单
|
|
|
- q.setTransAmount(DataUtil.convertAmount(spec.transAmount));
|
|
|
- q.setTransAmountSymbol(symbolOrDefault(spec.transAmountSymbol, SymbolEnum.EQ));
|
|
|
- }
|
|
|
- q.setStartDate(parseDate(spec.startDate, "startDate"));
|
|
|
- q.setEndDate(parseDate(spec.endDate, "endDate"));
|
|
|
- q.setPage(1);
|
|
|
- q.setLimit(ToolResultTable.normalizePageSize(spec.pageSize));
|
|
|
-
|
|
|
- Map<String, Object> result = graphService.nodeDetail(q);
|
|
|
- return ToolResults.ok(Json.toStr(result));
|
|
|
- } catch (Exception e) {
|
|
|
- return fail("图谱节点明细查询", e);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- /**
|
|
|
- * 读取图谱历史内容
|
|
|
- */
|
|
|
- @Tool(name = "get_graph_history_content",
|
|
|
- description = "读取某条关系图谱历史(快照)的内容 JSON。"
|
|
|
- + "★ 必须先调用 list_graph_history 拿到 id,再把该 id 传进来;id 不存在或文件已被清理会返回错误。"
|
|
|
- + "返回的 JSON 是保存图谱时前端存下的节点/连线数据,可直接据此复述或再喂给 render_graph 重新渲染。",
|
|
|
- readOnly = true)
|
|
|
- public ToolResultBlock getGraphHistoryContent(
|
|
|
- @ToolParam(name = "id", description = "关系图谱历史ID,取自 list_graph_history 返回的 id") Long id) {
|
|
|
- try {
|
|
|
- if (id == null) {
|
|
|
- return ToolResults.error("图谱历史不存在或内容为空: id=null");
|
|
|
- }
|
|
|
- String content = graphService.readGraphHisContent(id);
|
|
|
- if (content == null || content.trim().isEmpty()) {
|
|
|
- return ToolResults.error("图谱历史不存在或内容为空: id=" + id);
|
|
|
- }
|
|
|
- return ToolResults.ok(content);
|
|
|
- } catch (Exception e) {
|
|
|
- return fail("关系图谱历史内容读取", e);
|
|
|
- }
|
|
|
- }
|
|
|
|
|
|
// ==================== 内部实现 ====================
|
|
|
|
|
|
- /**
|
|
|
- * 分页取全量:页大小设为 {@code FETCH_LIMIT + 1},让数据库只回上限内的数据,
|
|
|
- * 同时用 {@code Page#getTotal()}(MP 的 count 查询)判断真实总数是否超限。
|
|
|
- */
|
|
|
- private static void applyFetchWindow(Query query) {
|
|
|
- query.setPage(1);
|
|
|
- query.setLimit(FETCH_LIMIT + 1L);
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 统一出口:超限报错,否则写入结果缓存并返回首页表格 JSON
|
|
|
- */
|
|
|
- private ToolResultBlock render(Page<?> page, Number pageSize) {
|
|
|
- long total = page.getTotal();
|
|
|
- if (total > FETCH_LIMIT) {
|
|
|
- return ToolResults.error("结果集过大(" + total + " 行,上限 " + FETCH_LIMIT
|
|
|
- + "),请收窄人员/卡号/号码/日期范围等条件后重试");
|
|
|
- }
|
|
|
- return ToolResultTable.store(sqlResultStore, null, page.getRecords(), 1,
|
|
|
- ToolResultTable.normalizePageSize(pageSize));
|
|
|
- }
|
|
|
|
|
|
/**
|
|
|
* 失败:区分「参数格式错」与「执行错」,后者补一句开案提示
|
|
|
@@ -420,29 +239,6 @@ public class GraphAnalysisTool {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 金额比较符白名单归一化。
|
|
|
- *
|
|
|
- * <p>绝不能把模型给的字符串原样拼进 SQL:Mapper 里是 {@code ${p.transAmountSymbol}}
|
|
|
- * ({@code GraphCallDetailQuery} 里更是把枚举直接取成字符串),是纯粹的文本替换。
|
|
|
- * 这里同时接受枚举名(GE/LE/…)与符号写法(>=/<=…),非法值直接报错。
|
|
|
- */
|
|
|
- private static SymbolEnum symbolOrDefault(String value, SymbolEnum defaultValue) {
|
|
|
- String text = trimToNull(value);
|
|
|
- if (text == null) {
|
|
|
- return defaultValue;
|
|
|
- }
|
|
|
- return switch (text.toUpperCase()) {
|
|
|
- case "GE", ">=" -> SymbolEnum.GE;
|
|
|
- case "LE", "<=" -> SymbolEnum.LE;
|
|
|
- case "GT", ">" -> SymbolEnum.GT;
|
|
|
- case "LT", "<" -> SymbolEnum.LT;
|
|
|
- case "EQ", "=" -> SymbolEnum.EQ;
|
|
|
- default -> throw new IllegalArgumentException(
|
|
|
- "transAmountSymbol 取值不合法(应为 GE/LE/GT/LT/EQ 或 >=/<=/>/</=):" + value);
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
* 图谱维度归一化:去 null、去重保序;空列表归一成 null
|
|
|
*/
|