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.RequestHeader;
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");
/** 反编造护栏: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 conversationId;
/** 透传的 ERP 会话 token(token 绝不进 prompt)。身份一律由它内省得出,不接受客户端自报。 */
public String authorization;
}
@PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8")
public SseEmitter chat(@RequestBody ChatReq req,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
SseEmitter emitter = new SseEmitter(180_000L);
final String userInput = req.text == null ? "" : req.text;
final AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization));
if (identity == null) {
send(emitter, "error", "登录已过期或未登录,请重新登录后再试。");
emitter.complete();
return emitter;
}
// 会话键绑定服务端身份 → 换个 conversationId 只能命中自己的会话,碰不到别人的记忆
final String convId = conversations.scopedId(identity, req.conversationId);
conversations.touch(identity.userId(), convId, userInput);
ledger.append(convId, "user", Map.of("text", userInput));
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;
}
public static class FormSubmitReq {
public String entity;
public Map fields; // 字段中文名 -> 值
public String conversationId;
public String authorization;
}
/**
* 确定性表单提交:collectForm 表单的结构化字段**直达** proposeWrite(action=create),
* 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。
*/
@PostMapping("/form/submit")
public Map formSubmit(@RequestBody FormSubmitReq req,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
Map out = new LinkedHashMap<>();
AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization));
if (identity == null) {
out.put("error", "登录已过期或未登录,请重新登录后再试。");
return out;
}
final String convId = conversations.scopedId(identity, req.conversationId);
String entity = req.entity == null ? "" : req.entity.trim();
Map fields = req.fields == null ? Map.of() : req.fields;
if (entity.isEmpty() || fields.isEmpty()) {
out.put("error", "缺少单据类型或表单字段。");
return out;
}
StringBuilder parts = new StringBuilder();
fields.forEach((k, v) -> {
if (v != null && !v.isBlank()) {
if (parts.length() > 0) parts.append(",");
parts.append(k).append("=").append(v);
}
});
String userText = "提交「" + entity + "」新增表单:" + parts;
conversations.touch(identity.userId(), convId, userText);
ledger.append(convId, "user", Map.of("text", userText));
String fieldsJson;
try {
fieldsJson = mapper.writeValueAsString(fields);
} catch (Exception e) {
out.put("error", "字段序列化失败:" + e.getMessage());
return out;
}
String result = agentFactory.proposeWriteTool(identity)
.proposeWrite("create", entity, null, null, null, fieldsJson);
try {
JsonNode r = mapper.readTree(result);
String opId = r.path("opId").asText(null);
if (opId != null && !opId.isBlank()) {
ops.attachConversation(opId, convId);
String summary = r.path("summary").asText("");
ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
state.setActiveDoc(convId, entity, "", opId, "proposed");
appendMemoryTurn(convId, userText, "已生成待确认提议:" + summary + "(等待用户点确认/取消)");
out.put("opId", opId);
out.put("summary", summary);
out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。"));
} else {
String err = r.path("error").asText("无法完成该新增。");
ledger.append(convId, "assistant", Map.of("text", err));
appendMemoryTurn(convId, userText, err);
out.put("error", err);
}
} catch (Exception e) {
out.put("error", "服务异常:" + e.getMessage());
}
return out;
}
/** 意图门 + 确定性路由。表单提交不再走这里——见 {@link #formSubmit}(结构化直达,确定性)。 */
private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) {
// 意图门(带上一轮留下的状态槽;失败时返回 其他,走兜底)。
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), it.failed);
}
}
/** 状态槽注入在用户消息尾部(空状态时原样返回,保持 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