missing = new ArrayList<>(); // 完成该意图还缺的关键信息
diff --git a/src/main/java/com/xly/agent/ReActAgent.java b/src/main/java/com/xly/agent/ReActAgent.java
index 8976164..8d1191c 100644
--- a/src/main/java/com/xly/agent/ReActAgent.java
+++ b/src/main/java/com/xly/agent/ReActAgent.java
@@ -11,8 +11,8 @@ import dev.langchain4j.service.UserMessage;
* 调用哪个工具、以及何时给出最终答复。不再有旧的 8 场景 {@code SceneSelector} 路由。
*
* 但 agent 也**不是完全自路由**:{@code AgentChatController} 先用意图门({@code IntentService})
- * 判类,再由 {@link com.xly.config.AgentFactory} 按 {@link ToolScope} 只把该意图相关的工具装进本实例
- * (新增意图更是完全绕过 ReAct,走确定性 collectForm 路径)。本接口只负责「给定这批工具,自己决定怎么用」。
+ * 判类,新增/操作已有单据走确定性路径(collectForm / proposeWrite)完全绕过 ReAct;
+ * 本接口只负责「给定固定的 6 个工具,自己决定怎么用」。
*
*
返回 {@link TokenStream} 以支持流式输出;{@link MemoryId} 绑定每个会话独立的对话记忆。
*/
diff --git a/src/main/java/com/xly/agent/ToolScope.java b/src/main/java/com/xly/agent/ToolScope.java
deleted file mode 100644
index 8c041ee..0000000
--- a/src/main/java/com/xly/agent/ToolScope.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.xly.agent;
-
-/**
- * 按意图收窄「本轮可见的工具集」(架构 §5 的 tool scoping)。
- *
- *
依据:单次 ReAct 的选工具准确率随工具数量上升而骤降(业界经验:50 个工具 ~94% → 200 个 ~64%)。
- * 弱模型(qwen3:14b Q4)一次性面对全部 9 个语义重叠的工具时,频繁选错族(把「查询」当「新增」)。
- * 于是先用意图门判类,再**只暴露该意图相关的 5 个工具**给 ReAct 执行——大幅降低选错概率。
- */
-public enum ToolScope {
- /** 读:findForms/kgSearch/readFormData/lookupRecord/queryData(+askUser/loadSkill)。 */
- READ,
- /** 写:collectForm/proposeWrite(action=…)(+readFormData·lookupRecord 定位、askUser/loadSkill)。 */
- WRITE,
- /** 兜底:全部 9 个工具(意图门失败时用)。 */
- FULL
-}
diff --git a/src/main/java/com/xly/config/AgentFactory.java b/src/main/java/com/xly/config/AgentFactory.java
index 297dbfc..62bfbb1 100644
--- a/src/main/java/com/xly/config/AgentFactory.java
+++ b/src/main/java/com/xly/config/AgentFactory.java
@@ -3,22 +3,16 @@ package com.xly.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import com.xly.agent.ReActAgent;
-import com.xly.agent.ToolScope;
-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.chat.ChatModel;
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.service.AiServices;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -30,16 +24,16 @@ import org.springframework.stereotype.Component;
*
*
为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程,
* ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的
- * 工具实例里(ErpReadTool/ProposeWriteTool/QueryTool/FormCollectTool),从根上保证鉴权与 token 正确。
+ * 工具实例里(ErpReadTool/ProposeWriteTool/FormCollectTool),从根上保证鉴权与 token 正确。
*
- *
无状态、全局的工具({@link KgQueryTool} 表单目录/KG、{@link SkillTool} Skill 加载、
- * {@link InteractionTool} AskUser)是单例、跨请求复用。模型、记忆存储、system prompt 也复用。
+ *
工具集**固定 6 个**(findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser),
+ * 不再按意图分范围:n=50 基准显示分范围收不到准确率收益,反而破坏 KV-cache 前缀稳定性。
+ * 无状态、全局的工具({@link KgQueryTool}、{@link InteractionTool})是单例、跨请求复用。
*/
@Component
public class AgentFactory {
private final StreamingChatModel streamingModel;
- private final ChatModel sqlModel;
private final RedisChatMemoryStore memoryStore;
private final SystemPromptService systemPromptService;
@@ -48,22 +42,16 @@ public class AgentFactory {
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;
public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel,
- @Qualifier("sqlChatModel") ChatModel 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) {
+ ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool) {
this.streamingModel = streamingModel;
- this.sqlModel = sqlModel;
this.memoryStore = memoryStore;
this.systemPromptService = systemPromptService;
this.erp = erp;
@@ -71,45 +59,19 @@ public class AgentFactory {
this.resolver = resolver;
this.ops = ops;
this.mapper = mapper;
- this.audit = audit;
- this.skillService = skillService;
this.kgQueryTool = kgQueryTool;
- this.skillTool = skillTool;
this.interactionTool = interactionTool;
}
- /** 兜底:全部工具(意图门失败时用)。 */
- public ReActAgent build(AgentIdentity identity) {
- return build(identity, ToolScope.FULL);
- }
-
/**
- * 按意图范围组装 ReAct agent:只暴露该范围相关的工具 + 对应版本的 system prompt。
- *
- *
收窄可见工具(3-5 个)显著提升弱模型的选工具准确率;{@code maxSequentialToolsInvocations}
+ * 组装 ReAct agent:固定 6 工具 + 唯一 system prompt。{@code maxSequentialToolsInvocations}
* 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。
*/
- public ReActAgent build(AgentIdentity identity, ToolScope scope) {
- String systemPrompt = systemPromptService.buildPrompt(scope);
-
- ErpReadTool readTool = new ErpReadTool(erp, jdbc, resolver, identity);
- Object[] tools;
- switch (scope) {
- case READ:
- tools = new Object[]{kgQueryTool, skillTool, interactionTool, readTool,
- new QueryTool(sqlModel, jdbc, audit, identity, resolver)};
- break;
- case WRITE:
- tools = new Object[]{skillTool, interactionTool, readTool,
- new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver),
- new FormCollectTool(jdbc, resolver, identity, mapper)};
- break;
- default: // FULL
- tools = new Object[]{kgQueryTool, skillTool, interactionTool, readTool,
- new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver),
- new QueryTool(sqlModel, jdbc, audit, identity, resolver),
- new FormCollectTool(jdbc, resolver, identity, mapper)};
- }
+ public ReActAgent build(AgentIdentity identity) {
+ Object[] tools = new Object[]{kgQueryTool, interactionTool,
+ new ErpReadTool(erp, resolver, identity),
+ new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver),
+ new FormCollectTool(jdbc, resolver, identity, mapper)};
return AiServices.builder(ReActAgent.class)
.streamingChatModel(streamingModel)
@@ -120,7 +82,7 @@ public class AgentFactory {
.maxMessages(30)
.chatMemoryStore(memoryStore)
.build())
- .systemMessageProvider(memoryId -> systemPrompt)
+ .systemMessageProvider(memoryId -> systemPromptService.prompt())
.build();
}
diff --git a/src/main/java/com/xly/config/ModelConfig.java b/src/main/java/com/xly/config/ModelConfig.java
index 3044073..ee56f1e 100644
--- a/src/main/java/com/xly/config/ModelConfig.java
+++ b/src/main/java/com/xly/config/ModelConfig.java
@@ -3,44 +3,13 @@ package com.xly.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
-import dev.langchain4j.model.chat.ChatModel;
-import dev.langchain4j.model.openai.OpenAiChatModel;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
-import java.time.Duration;
-import java.util.List;
-
@Configuration
public class ModelConfig {
- @Value("${llm.base-url}")
- private String baseUrl;
-
- @Value("${llm.api-key:ollama}")
- private String apiKey;
-
- @Value("${llm.sql-model}")
- private String sqlModelName;
-
- /** NL2SQL 专用模型(QueryTool 经 AgentFactory 注入),OpenAI 兼容协议。 */
- @Bean("sqlChatModel")
- public ChatModel sqlChatModel(TracingChatModelListener tracingListener) {
- return OpenAiChatModel.builder()
- .baseUrl(baseUrl)
- .apiKey(apiKey)
- .modelName(sqlModelName)
- .temperature(0.0)
- .topP(0.95)
- .reasoningEffort("none")
- .listeners(List.of(tracingListener))
- .timeout(Duration.ofSeconds(120))
- .maxRetries(3)
- .build();
- }
-
@Bean
@Primary
public ObjectMapper objectMapper() {
diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java
index f699662..248b089 100644
--- a/src/main/java/com/xly/service/FormResolverService.java
+++ b/src/main/java/com/xly/service/FormResolverService.java
@@ -83,6 +83,15 @@ public class FormResolverService {
"ORDER BY iFormUses DESC LIMIT 1", table);
}
+ /** 同 {@link #resolveNameField},但从 formId 出发(经表单目录找到数据源表)。 */
+ public String resolveNameFieldByFormId(String formId) {
+ return queryOne(
+ "SELECT fd.sField FROM viw_ai_useful_forms af " +
+ "JOIN viw_kg_field_dict fd ON fd.sTable = af.sDataSource " +
+ "WHERE af.sFormId = ? AND fd.sField LIKE '%Name' AND fd.sField NOT LIKE '%NameId' " +
+ "ORDER BY fd.iFormUses DESC LIMIT 1", formId);
+ }
+
// ERP 会自动注入 / 系统管理、不该让用户填的列(新增时排除)。
private static final Set SYS_EXACT = Set.of(
"sId", "sBrandsId", "sSubsidiaryId", "sMakePerson", "sFormId", "sBillNo", "iIncrement", "iOrder",
diff --git a/src/main/java/com/xly/service/IntentService.java b/src/main/java/com/xly/service/IntentService.java
index 8ca800a..0d67165 100644
--- a/src/main/java/com/xly/service/IntentService.java
+++ b/src/main/java/com/xly/service/IntentService.java
@@ -11,14 +11,14 @@ import java.util.List;
import java.util.Map;
/**
- * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。
+ * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图(4 类), 单据类型, 带角色的实体, 缺失信息}。
*
- * 只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。实测在此形态下
- * qwen3:14b 对包括「报价纸盒→纸盒是产品而非客户」「给苏州华为报价彩盒→客户+产品分离」
- * 「有多少个客户→查询」在内的样本 8/8 正确、~2-3s/次。相较之下,让同一个模型在全部 9 个工具的
- * 单次 ReAct 里同时判意图/选工具/编参数则频繁出错(把查询错当新增、更新流程死循环等)。
+ *
只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。提示只给**类定义**
+ * (含有界的行业常识,如 定制品问价=新建报价),不写逐案纠错规则——错误种类无穷,
+ * 按案例打补丁写不完;观察到的错例进回归基准(bench50)而不是提示。
+ * 具体写动作(update/invalid/examine…)由路由层从原话推出,不在门里细分。
*
- *
失败(模型不可达/JSON 异常)时返回 intent=不清楚 的空结果,调用方降级到通用 agent,绝不中断。
+ *
失败(模型不可达/JSON 异常)时返回 intent=其他 的空结果,调用方降级到通用 agent,绝不中断。
*/
@Service
public class IntentService {
@@ -26,18 +26,17 @@ public class IntentService {
private static final Logger log = LoggerFactory.getLogger(IntentService.class);
private static final String SYSTEM =
- "你是印刷/包装 ERP 的意图与实体抽取器。给你一句用户话,判断其操作意图"
- + "(查询/新增/修改/删除/审核/闲聊/不清楚)、涉及的单据或实体类型,并抽取句中出现的实体及其业务角色"
- + "(客户/产品/物料/供应商/数量/尺寸/日期/金额)。规则:"
- + "①『报价X』『给X报价』里,若 X 是物品(如 纸盒/彩盒/画册/小册子)则 X 是**产品**,不是客户;"
- + "只有明确出现公司/客户名(如『给苏州华为报价』)时才把它当**客户**。"
- + "②对某个产品/规格问价——『多少钱』『什么价』『报个价』『报价』『价格是多少』——在印刷/包装行业"
- + "**通常是要新建一张报价单**(价格由系统核价算出,系统里没有该定制活的现成价),intent=**新增**、单据=**报价**;"
- + "只有明确查看/统计**已存在**的单据(给了单号,或说『查已有报价/列出报价单/上月报价额』)才是查询。"
- + "③『有多少X/查一下X/X的电话是多少』这类问既有数据是**查询**,不是新增。"
- + "④只说一个名词(如『报价』『纸盒』)而无动作时,intent=不清楚。"
- + "⑤missing 里列出完成该意图还缺的关键信息(如 修改缺『具体记录』『新值』)。"
- + "只输出 JSON,不要解释。";
+ "你是印刷/包装 ERP 的意图与实体抽取器。把一句用户话分类为四种意图之一,并抽取句中实体及其业务角色。\n"
+ + "意图定义:\n"
+ + "- 查询:查看、统计或查找系统里**已有**的数据/单据。\n"
+ + "- 新增:要创建一张新单据或一条新记录。行业常识:对定制产品问价格(多少钱/什么价/报个价)"
+ + "就是要**新建报价单**——价格由系统核价算出,没有现成价;只有给了单号或明确说查已有报价才算查询。\n"
+ + "- 操作已有单据:对系统里已存在的某条单据/记录做修改、作废/删除、审核/反审核、复原。\n"
+ + "- 其他:闲聊、问候、与 ERP 业务无关,或信息太少无法判断(如只说一个名词)。\n"
+ + "实体角色:客户=购买方的公司/单位名;产品=要生产/加工/报价的物品(如纸盒、彩盒、画册);其余按字面。\n"
+ + "danju=该意图针对的单据或实体类型:新增时=要新建的单据类型(如问价即 报价),"
+ + "查询/操作时=要查或要操作的单据/实体(如 客户/销售订单);确实没有才留空。"
+ + "missing=完成该意图还缺的关键信息。只输出 JSON,不要解释。";
private final LlmJsonClient llm;
@@ -45,7 +44,7 @@ public class IntentService {
this.llm = llm;
}
- /** 意图门主入口。utterance 为空或模型失败时返回不清楚。 */
+ /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */
public Intent classify(String utterance) {
Intent out = new Intent();
if (utterance == null || utterance.isBlank()) {
@@ -53,10 +52,10 @@ public class IntentService {
}
JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema());
if (n == null) {
- log.warn("intent classify fell back to UNCLEAR (model unavailable)");
+ log.warn("intent classify fell back to 其他 (model unavailable)");
return out;
}
- String intent = n.path("intent").asText(Intent.UNCLEAR);
+ String intent = n.path("intent").asText(Intent.OTHER);
out.intent = normalizeIntent(intent);
out.danju = n.path("danju").asText("");
JsonNode es = n.path("entities");
@@ -117,19 +116,16 @@ public class IntentService {
}
private static String normalizeIntent(String s) {
- if (s == null) return Intent.UNCLEAR;
+ if (s == null) return Intent.OTHER;
s = s.trim();
switch (s) {
case Intent.QUERY:
case Intent.CREATE:
- case Intent.UPDATE:
- case Intent.DELETE:
- case Intent.EXAMINE:
- case Intent.CHAT:
- case Intent.UNCLEAR:
+ case Intent.OPERATE:
+ case Intent.OTHER:
return s;
default:
- return Intent.UNCLEAR;
+ return Intent.OTHER;
}
}
@@ -145,7 +141,7 @@ public class IntentService {
Map props = new LinkedHashMap<>();
props.put("intent", Map.of("type", "string",
- "enum", List.of("查询", "新增", "修改", "删除", "审核", "闲聊", "不清楚")));
+ "enum", List.of("查询", "新增", "操作已有单据", "其他")));
props.put("danju", Map.of("type", "string"));
props.put("entities", Map.of("type", "array", "items", entityItem));
props.put("missing", Map.of("type", "array", "items", Map.of("type", "string")));
diff --git a/src/main/java/com/xly/service/SkillService.java b/src/main/java/com/xly/service/SkillService.java
deleted file mode 100644
index ec7ecff..0000000
--- a/src/main/java/com/xly/service/SkillService.java
+++ /dev/null
@@ -1,57 +0,0 @@
-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