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.config.AgentFactory;
import com.xly.config.RedisChatMemoryStore;
import com.xly.service.AuthzService;
import com.xly.service.ConversationService;
import com.xly.service.FormResolverService;
import com.xly.service.IntentService;
import com.xly.service.LedgerService;
import com.xly.service.OpService;
import com.xly.service.SlotFillService;
import com.xly.service.StateService;
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;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
/**
* 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。
*
*
编排(架构 §5 意图门 + 确定性路由):每轮先用 {@link IntentService} 做**受约束 JSON**
* 的 4 类意图+实体分类,再确定性路由:
*
* - 新增 → 直接用 {@link SlotFillService} 按表单真实字段做受约束槽位填充,弹 collectForm 表单
* (完全不经 LLM 选工具,从机制上杜绝「把产品当客户」);
* - 操作已有单据 → 写槽位抽取 + 确定性 proposeWrite(人在环,具体动作从原话推出);
* - 查询/其他/分类失败 → 统一的 6 工具 ReAct agent(查询附意图 grounding)。
*
* 确定性路径 + 意图 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)";
/** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */
private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)");
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 LedgerService ledger;
private final StateService state;
private final RedisChatMemoryStore memoryStore;
private final ExecutorService exec = Executors.newCachedThreadPool();
public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
ConversationService conversations, OpService ops,
IntentService intentService, SlotFillService slotFill,
FormResolverService resolver, LedgerService ledger,
StateService state, RedisChatMemoryStore memoryStore) {
this.agentFactory = agentFactory;
this.authz = authz;
this.mapper = mapper;
this.conversations = conversations;
this.ops = ops;
this.intentService = intentService;
this.slotFill = slotFill;
this.resolver = resolver;
this.ledger = ledger;
this.state = state;
this.memoryStore = memoryStore;
}
public static class ChatReq {
public String text;
public String userid;
public String conversationId;
// 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt)
public String authorization;
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);
ledger.append(convId, "user", Map.of("text", 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, userInput, false);
return;
}
// 1) 意图门(带上一轮留下的状态槽;失败时返回 其他,走兜底)。
String digest = state.digest(convId);
Intent it = intentService.classify(userInput, digest);
state.recordIntent(convId, it.intent, it.danju);
state.mergeEntities(convId, it.entities);
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, withState(ground(userInput, it), digest), false);
return;
case Intent.OPERATE:
handleWrite(emitter, convId, identity, userInput, it, digest);
return;
case Intent.QUERY:
runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true);
return;
default:
// 其他/分类失败:原文交给 agent,尽量不丢能力。
runAgent(emitter, convId, identity, withState(userInput, digest), false);
}
}
/** 状态槽注入在用户消息尾部(空状态时原样返回,保持 KV 前缀稳定)。 */
private static String withState(String text, String digest) {
if (digest == null || digest.isBlank()) {
return text;
}
return text + "\n\n(会话状态,仅供参考:" + digest + ")";
}
/**
* 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 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