package com.xly.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import com.xly.agent.Intent;
import com.xly.agent.ReActAgent;
import com.xly.agent.ToolScope;
import com.xly.config.AgentFactory;
import com.xly.service.AuthzService;
import com.xly.service.ConversationService;
import com.xly.service.FormResolverService;
import com.xly.service.IntentService;
import com.xly.service.OpService;
import com.xly.service.SlotFillService;
import com.xly.tool.FormCollectTool;
import dev.langchain4j.service.TokenStream;
import dev.langchain4j.service.tool.ToolExecution;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。
*
*
新编排(架构 §5 意图门 + 确定性路由):不再让一个弱模型在全部 9 个工具里一次性盲选。
* 每轮先用 {@link IntentService} 做**受约束 JSON** 的意图+实体分类,再确定性路由:
*
* - 新增 → 直接用 {@link SlotFillService} 按表单真实字段做受约束槽位填充,弹 collectForm 表单
* (完全不经 LLM 选工具,从机制上杜绝「把产品当客户」);
* - 查询 → 只暴露读工具的 READ-scope agent;
* - 修改/删除/审核 → 只暴露写工具的 WRITE-scope agent(人在环);
* - 不清楚/分类失败 → 兜底 FULL-scope,原文交给 agent。
*
* 收窄工具集 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。
*
* 帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 {@code question}、
* 表单收集 {@code form_collect}。确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。
*/
@RestController
@RequestMapping("/api/agent")
public class AgentChatController {
private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
private static final Set WRITE_TOOLS = Set.of("proposeWrite");
/** 前端 collectForm 表单提交后拼出的消息带此标记 → 本轮直接走「写」执行 proposeWrite(action=create)。 */
private static final String FORM_SUBMIT_MARK = "proposeWrite(action=create)";
private final AgentFactory agentFactory;
private final AuthzService authz;
private final ObjectMapper mapper;
private final ConversationService conversations;
private final OpService ops;
private final IntentService intentService;
private final SlotFillService slotFill;
private final FormResolverService resolver;
private final ExecutorService exec = Executors.newCachedThreadPool();
public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
ConversationService conversations, OpService ops,
IntentService intentService, SlotFillService slotFill,
FormResolverService resolver) {
this.agentFactory = agentFactory;
this.authz = authz;
this.mapper = mapper;
this.conversations = conversations;
this.ops = ops;
this.intentService = intentService;
this.slotFill = slotFill;
this.resolver = resolver;
}
public static class ChatReq {
public String text;
public String userid;
public String conversationId;
// 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt)
public String authorization;
public String username;
public String brandsid;
public String subsidiaryid;
public String usertype;
}
@PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8")
public SseEmitter chat(@RequestBody ChatReq req) {
SseEmitter emitter = new SseEmitter(180_000L);
final String userInput = req.text == null ? "" : req.text;
final String convId = (req.conversationId != null && !req.conversationId.isBlank())
? req.conversationId
: ((req.userid == null ? "anon" : req.userid) + ":default");
conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput);
final AgentIdentity identity = resolveIdentity(req);
exec.submit(() -> {
try {
route(emitter, convId, identity, userInput);
} catch (Exception e) {
log.error("agent route failed (conv={})", convId, e);
send(emitter, "error", "服务异常:" + e.getMessage());
emitter.complete();
}
});
return emitter;
}
/** 意图门 + 确定性路由。 */
private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) {
// 0) 表单提交 → 直接走写(执行 proposeWrite(action=create)),不再重新分类。
if (userInput.contains(FORM_SUBMIT_MARK)) {
runAgent(emitter, convId, identity, ToolScope.WRITE, userInput);
return;
}
// 1) 意图门(失败时返回 UNCLEAR,走兜底)。
Intent it = intentService.classify(userInput);
log.info("intent(conv={}): {} / {} / entities={} / missing={}",
convId, it.intent, it.danju, it.describeEntities(), it.missing);
switch (it.intent) {
case Intent.CREATE:
if (handleCreate(emitter, convId, identity, userInput, it)) {
return;
}
// 无法确定性建表单 → 交给写 agent 处理(可能需要它先问清单据类型)。
runAgent(emitter, convId, identity, ToolScope.WRITE, ground(userInput, it));
return;
case Intent.UPDATE:
case Intent.DELETE:
case Intent.EXAMINE:
handleWrite(emitter, convId, identity, userInput, it);
return;
case Intent.QUERY:
runAgent(emitter, convId, identity, ToolScope.READ, ground(userInput, it));
return;
case Intent.CHAT:
runAgent(emitter, convId, identity, ToolScope.READ, userInput);
return;
case Intent.UNCLEAR:
default:
// 分类不确定/失败:给全部工具、原文交给 agent,尽量不丢能力。
runAgent(emitter, convId, identity, ToolScope.FULL, userInput);
}
}
/**
* 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具,
* 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。
*/
private boolean handleCreate(SseEmitter emitter, String convId, AgentIdentity identity,
String userInput, Intent it) {
String entity = it.danju == null ? "" : it.danju.trim();
if (entity.isEmpty()) {
return false;
}
Map form = resolver.resolveMasterForm(entity);
if (form == null) {
return false;
}
String table = String.valueOf(form.get("sDataSource"));
// 确定性槽位映射:角色实体(意图门已给) + 正则尺寸/数量 → 真实字段,绝不让模型乱放槽位。
List