From 8d3905a2580b520dce60731d0216bbb1a6414234 Mon Sep 17 00:00:00 2001
From: zichun <26684461+reporkey@users.noreply.github.com>
Date: Wed, 22 Jul 2026 10:08:07 +0800
Subject: [PATCH] feat: per-request identity (token pass-through §7) + examine write + AskUser + FormCollect + Skills + KgSearch
---
src/main/java/com/xly/agent/AgentIdentity.java | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main/java/com/xly/config/AgentConfig.java | 40 +++++++---------------------------------
src/main/java/com/xly/config/AgentFactory.java | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main/java/com/xly/service/AuthzService.java | 23 +++++++++++++++++++++++
src/main/java/com/xly/service/ErpClient.java | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
src/main/java/com/xly/service/SkillService.java | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main/java/com/xly/service/SystemPromptService.java | 39 +++++++++++++++++++++++++++------------
src/main/java/com/xly/tool/ErpReadTool.java | 18 ++++++++----------
src/main/java/com/xly/tool/FormCollectTool.java | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
src/main/java/com/xly/tool/InteractionTool.java | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main/java/com/xly/tool/KgQueryTool.java | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main/java/com/xly/tool/ProposeWriteTool.java | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
src/main/java/com/xly/tool/QueryTool.java | 8 ++++----
src/main/java/com/xly/tool/SkillTool.java | 42 ++++++++++++++++++++++++++++++++++++++++++
src/main/java/com/xly/web/AgentChatController.java | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
src/main/java/com/xly/web/OpController.java | 19 ++++++++++++++-----
src/main/resources/templates/chat.html | 37 +++++++++++++++++++++++++++++++++++++
17 files changed, 884 insertions(+), 138 deletions(-)
create mode 100644 src/main/java/com/xly/agent/AgentIdentity.java
create mode 100644 src/main/java/com/xly/config/AgentFactory.java
create mode 100644 src/main/java/com/xly/service/SkillService.java
create mode 100644 src/main/java/com/xly/tool/InteractionTool.java
create mode 100644 src/main/java/com/xly/tool/SkillTool.java
diff --git a/src/main/java/com/xly/agent/AgentIdentity.java b/src/main/java/com/xly/agent/AgentIdentity.java
new file mode 100644
index 0000000..ae85318
--- /dev/null
+++ b/src/main/java/com/xly/agent/AgentIdentity.java
@@ -0,0 +1,78 @@
+package com.xly.agent;
+
+import java.util.Set;
+
+/**
+ * 单次 agent 调用的「按调用上下文」身份(架构 §5/§7 的 per-call context)。
+ *
+ *
由 {@code AgentChatController} 从前端请求解析(透传的 ERP 登录 token + 稳定身份),随
+ * {@link com.xly.config.AgentFactory} 注入到**每请求新建**的工具实例里——因为 LangChain4j 流式
+ * 工具执行发生在模型 HTTP 回调线程而非控制器工作线程,ThreadLocal 不可靠,只能把身份放进工具实例本身。
+ *
+ *
{@link #token} = 透传的用户 ERP 会话 token(可空 → 退回 dev-login)。{@link #grantedModuleIds}
+ * = 该用户实际有权的表单/菜单 id 集合({@code null} = 管理员/全部),Read/Query/Write 共用此边界。
+ * token 绝不进 prompt / LLM 可见文本。
+ */
+public final class AgentIdentity {
+
+ private final String token;
+ private final String userId;
+ private final String userNo;
+ private final String brandsId;
+ private final String subsidiaryId;
+ private final String userType;
+ private final Set grantedModuleIds; // null = 全部(管理员)
+
+ public AgentIdentity(String token, String userId, String userNo, String brandsId,
+ String subsidiaryId, String userType, Set grantedModuleIds) {
+ this.token = token;
+ this.userId = userId;
+ this.userNo = userNo;
+ this.brandsId = brandsId;
+ this.subsidiaryId = subsidiaryId;
+ this.userType = userType;
+ this.grantedModuleIds = grantedModuleIds;
+ }
+
+ /** 透传的用户 token;为空表示回退到 dev-login(本地开发)。 */
+ public String token() {
+ return token;
+ }
+
+ public boolean hasUserToken() {
+ return token != null && !token.isBlank();
+ }
+
+ public String userId() {
+ return userId;
+ }
+
+ public String userNo() {
+ return userNo;
+ }
+
+ public String brandsId() {
+ return brandsId;
+ }
+
+ public String subsidiaryId() {
+ return subsidiaryId;
+ }
+
+ public String userType() {
+ return userType;
+ }
+
+ /** null = 全部权限(管理员)。 */
+ public Set grantedModuleIds() {
+ return grantedModuleIds;
+ }
+
+ public boolean isAdminAll() {
+ return grantedModuleIds == null;
+ }
+
+ public boolean canAccessModule(String moduleId) {
+ return grantedModuleIds == null || (moduleId != null && grantedModuleIds.contains(moduleId));
+ }
+}
diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java
index 153afe6..340e5f1 100644
--- a/src/main/java/com/xly/config/AgentConfig.java
+++ b/src/main/java/com/xly/config/AgentConfig.java
@@ -1,25 +1,19 @@
package com.xly.config;
-import com.xly.agent.ReActAgent;
-import com.xly.service.SystemPromptService;
-import com.xly.tool.ErpReadTool;
-import com.xly.tool.KgQueryTool;
-import com.xly.tool.ProposeWriteTool;
-import com.xly.tool.QueryTool;
-import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.ollama.OllamaStreamingChatModel;
-import dev.langchain4j.service.AiServices;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
+import java.util.List;
/**
- * 组装单一 ReAct agent(M1)。
+ * agent 用的流式模型 Bean。
*
- * = 流式 Ollama 模型 + 通用工具(当前只有 {@link KgQueryTool})+ 每会话对话记忆
- * + 注入 L1 域图的 system prompt。取代旧的「每表单一个 ToolMeta 工具 + 8 场景路由」。
+ *
单一 ReAct agent 的**组装**已移到 {@link AgentFactory}(按每次请求的身份新建携带 token/权限的工具实例,
+ * 见 §5/§7 per-call context)。本类只保留全局复用的流式模型。
*/
@Configuration
public class AgentConfig {
@@ -30,7 +24,7 @@ public class AgentConfig {
@Value("${langchain4j.ollama.chat-model-name}")
private String chatModelName;
- @org.springframework.beans.factory.annotation.Autowired
+ @Autowired
private TracingChatModelListener tracingListener;
/** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */
@@ -45,28 +39,8 @@ public class AgentConfig {
// qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答
.think(false)
.returnThinking(false)
- .listeners(java.util.List.of(tracingListener))
+ .listeners(List.of(tracingListener))
.timeout(Duration.ofSeconds(180))
.build();
}
-
- @Bean
- public ReActAgent reActAgent(SystemPromptService systemPromptService,
- KgQueryTool kgQueryTool,
- ErpReadTool erpReadTool,
- ProposeWriteTool proposeWriteTool,
- QueryTool queryTool,
- RedisChatMemoryStore memoryStore) {
- String systemPrompt = systemPromptService.buildSystemPrompt();
- return AiServices.builder(ReActAgent.class)
- .streamingChatModel(agentStreamingModel())
- .tools(kgQueryTool, erpReadTool, proposeWriteTool, queryTool)
- .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
- .id(memoryId)
- .maxMessages(30)
- .chatMemoryStore(memoryStore)
- .build())
- .systemMessageProvider(memoryId -> systemPrompt)
- .build();
- }
}
diff --git a/src/main/java/com/xly/config/AgentFactory.java b/src/main/java/com/xly/config/AgentFactory.java
new file mode 100644
index 0000000..bfb5a8c
--- /dev/null
+++ b/src/main/java/com/xly/config/AgentFactory.java
@@ -0,0 +1,117 @@
+package com.xly.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xly.agent.AgentIdentity;
+import com.xly.agent.ReActAgent;
+import com.xly.service.AuditService;
+import com.xly.service.ErpClient;
+import com.xly.service.FormResolverService;
+import com.xly.service.OpService;
+import com.xly.service.SkillService;
+import com.xly.service.SystemPromptService;
+import com.xly.tool.ErpReadTool;
+import com.xly.tool.FormCollectTool;
+import com.xly.tool.InteractionTool;
+import com.xly.tool.KgQueryTool;
+import com.xly.tool.ProposeWriteTool;
+import com.xly.tool.QueryTool;
+import com.xly.tool.SkillTool;
+import dev.langchain4j.memory.chat.MessageWindowChatMemory;
+import dev.langchain4j.model.ollama.OllamaChatModel;
+import dev.langchain4j.model.ollama.OllamaStreamingChatModel;
+import dev.langchain4j.service.AiServices;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * 按「每次请求的身份」组装单一 ReAct agent(架构 §5/§7 的 per-call context)。
+ *
+ *
为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程,
+ * ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的
+ * 工具实例里(ErpReadTool/ProposeWriteTool/QueryTool/FormCollectTool),从根上保证鉴权与 token 正确。
+ *
+ *
无状态、全局的工具({@link KgQueryTool} 表单目录/KG、{@link SkillTool} Skill 加载、
+ * {@link InteractionTool} AskUser)是单例、跨请求复用。模型、记忆存储、system prompt 也复用。
+ */
+@Component
+public class AgentFactory {
+
+ private final OllamaStreamingChatModel streamingModel;
+ private final OllamaChatModel sqlModel;
+ private final RedisChatMemoryStore memoryStore;
+ private final SystemPromptService systemPromptService;
+
+ private final ErpClient erp;
+ private final JdbcTemplate jdbc;
+ private final FormResolverService resolver;
+ private final OpService ops;
+ private final ObjectMapper mapper;
+ private final AuditService audit;
+ private final SkillService skillService;
+
+ private final KgQueryTool kgQueryTool;
+ private final SkillTool skillTool;
+ private final InteractionTool interactionTool;
+
+ private volatile String cachedSystemPrompt;
+
+ public AgentFactory(@Qualifier("agentStreamingModel") OllamaStreamingChatModel streamingModel,
+ @Qualifier("sqlChatModel") OllamaChatModel sqlModel,
+ RedisChatMemoryStore memoryStore,
+ SystemPromptService systemPromptService,
+ ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops,
+ ObjectMapper mapper, AuditService audit, SkillService skillService,
+ KgQueryTool kgQueryTool, SkillTool skillTool, InteractionTool interactionTool) {
+ this.streamingModel = streamingModel;
+ this.sqlModel = sqlModel;
+ this.memoryStore = memoryStore;
+ this.systemPromptService = systemPromptService;
+ this.erp = erp;
+ this.jdbc = jdbc;
+ this.resolver = resolver;
+ this.ops = ops;
+ this.mapper = mapper;
+ this.audit = audit;
+ this.skillService = skillService;
+ this.kgQueryTool = kgQueryTool;
+ this.skillTool = skillTool;
+ this.interactionTool = interactionTool;
+ }
+
+ /** system prompt 是全局的(L1 域图 + Skill 摘要),构建一次后缓存。 */
+ private String systemPrompt() {
+ String p = cachedSystemPrompt;
+ if (p == null) {
+ synchronized (this) {
+ if (cachedSystemPrompt == null) {
+ cachedSystemPrompt = systemPromptService.buildSystemPrompt();
+ }
+ p = cachedSystemPrompt;
+ }
+ }
+ return p;
+ }
+
+ /** 用给定身份组装一个 ReAct agent(工具实例携带该身份的 token 与权限边界)。 */
+ public ReActAgent build(AgentIdentity identity) {
+ String systemPrompt = systemPrompt();
+ return AiServices.builder(ReActAgent.class)
+ .streamingChatModel(streamingModel)
+ .tools(
+ kgQueryTool,
+ skillTool,
+ interactionTool,
+ new ErpReadTool(erp, jdbc, resolver, identity),
+ new ProposeWriteTool(erp, jdbc, ops, mapper, identity),
+ new QueryTool(sqlModel, jdbc, audit, identity),
+ new FormCollectTool(erp, jdbc, resolver, identity, mapper))
+ .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
+ .id(memoryId)
+ .maxMessages(30)
+ .chatMemoryStore(memoryStore)
+ .build())
+ .systemMessageProvider(memoryId -> systemPrompt)
+ .build();
+ }
+}
diff --git a/src/main/java/com/xly/service/AuthzService.java b/src/main/java/com/xly/service/AuthzService.java
index 2086d9b..f9715a7 100644
--- a/src/main/java/com/xly/service/AuthzService.java
+++ b/src/main/java/com/xly/service/AuthzService.java
@@ -36,6 +36,9 @@ public class AuthzService {
@Value("${erp.dev-login.usertype:sysadmin}")
private String devUserType;
+ @Value("${erp.dev-login.userid:}")
+ private String devUserIdOverride;
+
public AuthzService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@@ -46,6 +49,26 @@ public class AuthzService {
return isAllowed(granted, moduleId);
}
+ /**
+ * 构造 dev-login(本地开发)身份:token 为空 → ErpClient 回退 dev-login;权限集按 dev 账号解析
+ * (admin → null = 全部)。
+ */
+ public com.xly.agent.AgentIdentity devIdentity() {
+ String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId();
+ Set granted = grantedIds(uid, devUserType, devBrand, devSub);
+ return new com.xly.agent.AgentIdentity(null, uid, devUserNo, devBrand, devSub, devUserType, granted);
+ }
+
+ /**
+ * 构造透传的真实用户身份:token = 用户浏览器里的 ERP 登录 token(转发给 ERP),权限集按该用户
+ * 真实授权({@code sAuthsId})解析。用于生产环境按各用户真实权限收紧。
+ */
+ public com.xly.agent.AgentIdentity userIdentity(String token, String userId, String userNo,
+ String brandsId, String subsidiaryId, String userType) {
+ Set granted = grantedIds(userId, userType, brandsId, subsidiaryId);
+ return new com.xly.agent.AgentIdentity(token, userId, userNo, brandsId, subsidiaryId, userType, granted);
+ }
+
/** null = 全部(管理员);否则 = 有权的 id 集合。 */
public Set grantedIds(String userId, String userType, String brandsId, String subsidiaryId) {
if (isAdmin(userType)) {
diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java
index 55059d4..5009b2b 100644
--- a/src/main/java/com/xly/service/ErpClient.java
+++ b/src/main/java/com/xly/service/ErpClient.java
@@ -85,16 +85,38 @@ public class ErpClient {
return (t != null && !t.isBlank()) ? t : login();
}
+ /** 解析本次调用要用的 token:优先透传的用户 token,否则 dev-login。 */
+ private String resolveToken(String override) {
+ return (override != null && !override.isBlank()) ? override : token();
+ }
+
/**
- * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。
- * 会话过期(code=-2)时自动重登一次并重试。
+ * 是否允许在 code=-2(会话过期)时重登重试。
+ * 安全:只有 dev-login(override 为空)才允许重登;透传的用户 token 过期时
+ * 绝不用 dev(admin) 重登——否则会把某用户静默提权成管理员。用户 token 过期直接把 -2 返回,
+ * 由上层向对话推「登录过期」。
*/
+ private boolean canRelogin(String override) {
+ return override == null || override.isBlank();
+ }
+
+ /** 读取某表单一页数据(dev-login token,兼容旧调用)。 */
public JsonNode readForm(String formId, String moduleId, int page, int pageSize,
String filterField, String filterValue) {
- JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token());
- if (root.path("code").asInt() == -2) {
+ return readForm(null, formId, moduleId, page, pageSize, filterField, filterValue);
+ }
+
+ /**
+ * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。
+ * {@code authToken} 为透传的用户 token(可空 → dev-login)。dev-login 会话过期(code=-2)时自动重登重试;
+ * 用户 token 过期不重登(见 {@link #canRelogin})。
+ */
+ public JsonNode readForm(String authToken, String formId, String moduleId, int page, int pageSize,
+ String filterField, String filterValue) {
+ JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken));
+ if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
login();
- root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token());
+ root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken));
}
return root;
}
@@ -132,24 +154,67 @@ public class ErpClient {
* 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。
*/
public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) {
- JsonNode root = doUpdate(moduleId, table, billId, field, value, token());
- if (root.path("code").asInt() == -2) {
+ return updateForm(null, moduleId, table, billId, field, value);
+ }
+
+ public JsonNode updateForm(String authToken, String moduleId, String table, String billId, String field, String value) {
+ JsonNode root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken));
+ if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
login();
- root = doUpdate(moduleId, table, billId, field, value, token());
+ root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken));
}
return root;
}
/** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */
public JsonNode deleteForm(String moduleId, String table, String billId) {
- JsonNode root = doDelete(moduleId, table, billId, token());
- if (root.path("code").asInt() == -2) {
+ return deleteForm(null, moduleId, table, billId);
+ }
+
+ public JsonNode deleteForm(String authToken, String moduleId, String table, String billId) {
+ JsonNode root = doDelete(moduleId, table, billId, resolveToken(authToken));
+ if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
+ login();
+ root = doDelete(moduleId, table, billId, resolveToken(authToken));
+ }
+ return root;
+ }
+
+ /**
+ * 审核 / 反审核一条单据(ERP {@code /business/doExamine})。{@code iFlag}=1 审核、0 反审核(消审)。
+ * 审核逻辑由 ERP 按表单数据驱动的存储过程执行({@code gdsmodule.sProcName})。会话过期自动重登重试。
+ */
+ public JsonNode examineForm(String authToken, String moduleId, String billId, int iFlag) {
+ JsonNode root = doExamine(moduleId, billId, iFlag, resolveToken(authToken));
+ if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
login();
- root = doDelete(moduleId, table, billId, token());
+ root = doExamine(moduleId, billId, iFlag, resolveToken(authToken));
}
return root;
}
+ private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) {
+ try {
+ String url = baseUrl + "/business/doExamine?sModelsId=" + moduleId;
+ Map paramsMap = new LinkedHashMap<>();
+ paramsMap.put("sFormGuid", moduleId);
+ paramsMap.put("sGuid", billId);
+ paramsMap.put("iFlag", iFlag);
+ paramsMap.put("sSlaveId", "");
+ String body = mapper.writeValueAsString(Map.of("paramsMap", paramsMap));
+ HttpRequest req = HttpRequest.newBuilder(URI.create(url))
+ .header("Content-Type", "application/json;charset=UTF-8")
+ .header("Authorization", tok)
+ .timeout(Duration.ofSeconds(60))
+ .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
+ .build();
+ HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ return mapper.readTree(resp.body());
+ } catch (Exception e) {
+ throw new RuntimeException("ERP 审核异常: " + e.getMessage(), e);
+ }
+ }
+
private JsonNode doDelete(String moduleId, String table, String billId, String tok) {
try {
String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
@@ -176,9 +241,13 @@ public class ErpClient {
/** 取一个新主键 uuid(ERP `/getUuid`)。 */
public String newUuid() {
+ return newUuid(null);
+ }
+
+ public String newUuid(String authToken) {
try {
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid"))
- .header("Authorization", token())
+ .header("Authorization", resolveToken(authToken))
.timeout(Duration.ofSeconds(15))
.GET()
.build();
@@ -192,10 +261,14 @@ public class ErpClient {
/** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */
public JsonNode createForm(String table, Map columns) {
- JsonNode root = doCreate(table, columns, token());
- if (root.path("code").asInt() == -2) {
+ return createForm(null, table, columns);
+ }
+
+ public JsonNode createForm(String authToken, String table, Map columns) {
+ JsonNode root = doCreate(table, columns, resolveToken(authToken));
+ if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
login();
- root = doCreate(table, columns, token());
+ root = doCreate(table, columns, resolveToken(authToken));
}
return root;
}
diff --git a/src/main/java/com/xly/service/SkillService.java b/src/main/java/com/xly/service/SkillService.java
new file mode 100644
index 0000000..ec7ecff
--- /dev/null
+++ b/src/main/java/com/xly/service/SkillService.java
@@ -0,0 +1,57 @@
+package com.xly.service;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Skill 注册表服务(架构 §6)。
+ *
+ * Skill = 针对重复任务的 playbook(新建报价 / 月度对账 / 库存盘点)。渐进披露:
+ * {@link #promptDigest()} 把「name + 何时用」摘要注入 system prompt(便宜、常驻);
+ * {@link #load(String)} 在 {@code load_skill} 工具被调用时返回完整指令。数据在 {@code ai_skill} 表。
+ */
+@Service
+public class SkillService {
+
+ private final JdbcTemplate jdbc;
+
+ public SkillService(JdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ /** 供 system prompt 的 Skill 摘要(name + 何时用),一行一个。KG/表缺失时降级为空串。 */
+ public String promptDigest() {
+ try {
+ List