diff --git a/src/main/java/cn/kazusa/ai/assistant/config/AssistantProperties.java b/src/main/java/cn/kazusa/ai/assistant/config/AssistantProperties.java index ca6ce7c..d61fd62 100644 --- a/src/main/java/cn/kazusa/ai/assistant/config/AssistantProperties.java +++ b/src/main/java/cn/kazusa/ai/assistant/config/AssistantProperties.java @@ -1,9 +1,7 @@ package cn.kazusa.ai.assistant.config; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; /** @@ -11,19 +9,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties; */ @ConfigurationProperties(prefix = "assistant") public class AssistantProperties { - private String defaultTenantId = "customer_a"; private final Routing routing = new Routing(); private final Vector vector = new Vector(); private List domains = new ArrayList<>(); - public String getDefaultTenantId() { - return defaultTenantId; - } - - public void setDefaultTenantId(String defaultTenantId) { - this.defaultTenantId = defaultTenantId; - } - public Routing getRouting() { return routing; } @@ -42,7 +31,7 @@ public class AssistantProperties { public static class Routing { private boolean useLlm = true; - private String fallbackDomain = "inventory"; + private String fallbackDomain = "general"; public boolean isUseLlm() { return useLlm; @@ -85,12 +74,7 @@ public class AssistantProperties { public static class DomainConfig { private String id; private String name; - private String queryMode = "live"; private List keywords = new ArrayList<>(); - private List fields = new ArrayList<>(); - private Map statusMappings = new HashMap<>(); - private QuerySchema querySchema = new QuerySchema(); - private List sources = new ArrayList<>(); public String getId() { return id; @@ -108,14 +92,6 @@ public class AssistantProperties { this.name = name; } - public String getQueryMode() { - return queryMode; - } - - public void setQueryMode(String queryMode) { - this.queryMode = queryMode; - } - public List getKeywords() { return keywords; } @@ -123,124 +99,5 @@ public class AssistantProperties { public void setKeywords(List keywords) { this.keywords = keywords; } - - public List getFields() { - return fields; - } - - public void setFields(List fields) { - this.fields = fields; - } - - public Map getStatusMappings() { - return statusMappings; - } - - public void setStatusMappings(Map statusMappings) { - this.statusMappings = statusMappings; - } - - public QuerySchema getQuerySchema() { - return querySchema; - } - - public void setQuerySchema(QuerySchema querySchema) { - this.querySchema = querySchema; - } - - public List getSources() { - return sources; - } - - public void setSources(List sources) { - this.sources = sources; - } - } - - public static class QuerySchema { - private List allowedParams = new ArrayList<>(); - private Map> enums = new HashMap<>(); - - public List getAllowedParams() { - return allowedParams; - } - - public void setAllowedParams(List allowedParams) { - this.allowedParams = allowedParams; - } - - public Map> getEnums() { - return enums; - } - - public void setEnums(Map> enums) { - this.enums = enums; - } - } - - public static class DomainSource { - private String type; - private String url; - private String method = "POST"; - private String path; - private String itemsPath = ""; - private Map headers = new HashMap<>(); - private Map body = new HashMap<>(); - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getMethod() { - return method; - } - - public void setMethod(String method) { - this.method = method; - } - - public String getPath() { - return path; - } - - public void setPath(String path) { - this.path = path; - } - - public String getItemsPath() { - return itemsPath; - } - - public void setItemsPath(String itemsPath) { - this.itemsPath = itemsPath; - } - - public Map getHeaders() { - return headers; - } - - public void setHeaders(Map headers) { - this.headers = headers; - } - - public Map getBody() { - return body; - } - - public void setBody(Map body) { - this.body = body; - } } } diff --git a/src/main/java/cn/kazusa/ai/assistant/controller/IngestController.java b/src/main/java/cn/kazusa/ai/assistant/controller/IngestController.java index 448f593..1e7788f 100644 --- a/src/main/java/cn/kazusa/ai/assistant/controller/IngestController.java +++ b/src/main/java/cn/kazusa/ai/assistant/controller/IngestController.java @@ -1,6 +1,7 @@ package cn.kazusa.ai.assistant.controller; -import cn.kazusa.ai.assistant.service.AssistantService; +import cn.kazusa.ai.assistant.service.VectorStoreService; +import java.util.List; import java.util.Map; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -14,20 +15,30 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/ingest") public class IngestController { - private final AssistantService assistantService; + private final VectorStoreService vectorStoreService; - public IngestController(AssistantService assistantService) { - this.assistantService = assistantService; + public IngestController(VectorStoreService vectorStoreService) { + this.vectorStoreService = vectorStoreService; } @PostMapping public ResponseEntity ingest(@RequestBody Map payload) { - String tenantId = payload.get("tenant_id"); String domainId = payload.get("domain_id"); + String content = payload.get("content"); if (domainId == null || domainId.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "domain_id required")); } - assistantService.ingest(tenantId, domainId); - return ResponseEntity.ok(Map.of("ingested", true, "domain_id", domainId)); + if (content == null || content.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "content required")); + } + + String id = java.util.UUID.randomUUID().toString(); + float[] embedding = vectorStoreService.embed(content); + VectorStoreService.DocumentRecord record = new VectorStoreService.DocumentRecord( + id, content, Map.of("domain_id", domainId), embedding + ); + vectorStoreService.upsert(domainId, List.of(record)); + + return ResponseEntity.ok(Map.of("success", true, "id", id, "domain_id", domainId)); } } diff --git a/src/main/java/cn/kazusa/ai/assistant/controller/OpenAIController.java b/src/main/java/cn/kazusa/ai/assistant/controller/OpenAIController.java index c195a73..14ac83f 100644 --- a/src/main/java/cn/kazusa/ai/assistant/controller/OpenAIController.java +++ b/src/main/java/cn/kazusa/ai/assistant/controller/OpenAIController.java @@ -43,11 +43,12 @@ public class OpenAIController { if (question == null || question.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "user message required")); } + String customerId = request.getCustomerId() != null ? request.getCustomerId() : ""; if (Boolean.TRUE.equals(request.getStream())) { servletResponse.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); - return streamResponse(request, question); + return streamResponse(request, question, customerId); } - AssistantAnswer answer = assistantService.answer(request.getTenantId(), question); + AssistantAnswer answer = assistantService.answer(customerId, question); ChatCompletionResponse response = buildResponse(request.getModel(), answer.getAnswer()); Map meta = new HashMap<>(); meta.put("domain_id", answer.getDomainId()); @@ -59,11 +60,11 @@ public class OpenAIController { return ResponseEntity.ok(response); } - private StreamingResponseBody streamResponse(ChatCompletionRequest request, String question) { + private StreamingResponseBody streamResponse(ChatCompletionRequest request, String question, String customerId) { return outputStream -> { try { writeEvent(outputStream, buildChunk("assistant", null, request.getModel(), true)); - assistantService.streamAnswer(request.getTenantId(), question, token -> { + assistantService.streamAnswer(customerId, question, token -> { try { writeEvent(outputStream, buildChunk(null, token, request.getModel(), true)); } catch (Exception ex) { diff --git a/src/main/java/cn/kazusa/ai/assistant/entity/VectorDocument.java b/src/main/java/cn/kazusa/ai/assistant/entity/VectorDocument.java index 83f6689..5b38f56 100644 --- a/src/main/java/cn/kazusa/ai/assistant/entity/VectorDocument.java +++ b/src/main/java/cn/kazusa/ai/assistant/entity/VectorDocument.java @@ -13,7 +13,6 @@ import cn.kazusa.ai.assistant.typehandler.VectorFloatTypeHandler; public class VectorDocument { @TableId private String id; - private String tenantId; private String domainId; private String content; @TableField(typeHandler = JsonbTypeHandler.class) @@ -29,14 +28,6 @@ public class VectorDocument { this.id = id; } - public String getTenantId() { - return tenantId; - } - - public void setTenantId(String tenantId) { - this.tenantId = tenantId; - } - public String getDomainId() { return domainId; } diff --git a/src/main/java/cn/kazusa/ai/assistant/mapper/VectorDocumentMapper.java b/src/main/java/cn/kazusa/ai/assistant/mapper/VectorDocumentMapper.java index 574b81c..edc8253 100644 --- a/src/main/java/cn/kazusa/ai/assistant/mapper/VectorDocumentMapper.java +++ b/src/main/java/cn/kazusa/ai/assistant/mapper/VectorDocumentMapper.java @@ -14,7 +14,6 @@ import java.util.List; public interface VectorDocumentMapper extends BaseMapper { List similaritySearch( - @Param("tenantId") String tenantId, @Param("domainId") String domainId, @Param("embedding") String embedding, @Param("topK") int topK diff --git a/src/main/java/cn/kazusa/ai/assistant/model/ChatCompletionRequest.java b/src/main/java/cn/kazusa/ai/assistant/model/ChatCompletionRequest.java index 31b0ddd..54acbb9 100644 --- a/src/main/java/cn/kazusa/ai/assistant/model/ChatCompletionRequest.java +++ b/src/main/java/cn/kazusa/ai/assistant/model/ChatCompletionRequest.java @@ -1,5 +1,6 @@ package cn.kazusa.ai.assistant.model; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.ArrayList; import java.util.List; @@ -9,7 +10,8 @@ import java.util.List; public class ChatCompletionRequest { private String model; private Boolean stream; - private String tenantId; + @JsonProperty("customer_id") + private String customerId; private List messages = new ArrayList<>(); public String getModel() { @@ -28,12 +30,12 @@ public class ChatCompletionRequest { this.stream = stream; } - public String getTenantId() { - return tenantId; + public String getCustomerId() { + return customerId; } - public void setTenantId(String tenantId) { - this.tenantId = tenantId; + public void setCustomerId(String customerId) { + this.customerId = customerId; } public List getMessages() { diff --git a/src/main/java/cn/kazusa/ai/assistant/service/AssistantService.java b/src/main/java/cn/kazusa/ai/assistant/service/AssistantService.java index f9228ef..653f840 100644 --- a/src/main/java/cn/kazusa/ai/assistant/service/AssistantService.java +++ b/src/main/java/cn/kazusa/ai/assistant/service/AssistantService.java @@ -3,12 +3,10 @@ package cn.kazusa.ai.assistant.service; import cn.kazusa.ai.assistant.config.AssistantProperties; import cn.kazusa.ai.assistant.model.AssistantAnswer; import cn.kazusa.ai.assistant.service.DomainRouter.RouteResult; -import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.Prompt; @@ -21,29 +19,23 @@ import org.springframework.stereotype.Service; public class AssistantService { private final AssistantProperties properties; private final DomainRouter domainRouter; - private final DomainFetcher domainFetcher; private final VectorStoreService vectorStoreService; private final ChatModel chatModel; - private final ObjectMapper objectMapper; public AssistantService( AssistantProperties properties, DomainRouter domainRouter, - DomainFetcher domainFetcher, VectorStoreService vectorStoreService, - ChatModel chatModel, - ObjectMapper objectMapper + ChatModel chatModel ) { this.properties = properties; this.domainRouter = domainRouter; - this.domainFetcher = domainFetcher; this.vectorStoreService = vectorStoreService; this.chatModel = chatModel; - this.objectMapper = objectMapper; } - public AssistantAnswer answer(String tenantId, String question) { - ContextResult contextResult = buildContext(tenantId, question); + public AssistantAnswer answer(String customerId, String question) { + ContextResult contextResult = buildContext(question); String answer = callChatModel(contextResult.getContextText(), question); return new AssistantAnswer( answer, @@ -51,12 +43,12 @@ public class AssistantService { contextResult.getContextText(), contextResult.getDomainId(), contextResult.getRouteReason(), - contextResult.getParams() + Map.of() ); } - public void streamAnswer(String tenantId, String question, java.util.function.Consumer onToken) { - ContextResult contextResult = buildContext(tenantId, question); + public void streamAnswer(String customerId, String question, java.util.function.Consumer onToken) { + ContextResult contextResult = buildContext(question); Prompt prompt = buildPrompt(contextResult.getContextText(), question); chatModel.stream(prompt).toStream().forEach(response -> { String token = response.getResult().getOutput().getContent(); @@ -66,170 +58,11 @@ public class AssistantService { }); } - public void ingest(String tenantId, String domainId) { - AssistantProperties.DomainConfig domain = findDomain(domainId); - String resolvedTenant = tenantId == null || tenantId.isEmpty() ? properties.getDefaultTenantId() : tenantId; - List> records = domainFetcher.fetchRecords(domain, resolvedTenant, "", Map.of()); - List documents = toContextRecords(records, resolvedTenant, domain); - for (VectorStoreService.DocumentRecord doc : documents) { - float[] embedding = vectorStoreService.embed(doc.getContent()); - VectorStoreService.DocumentRecord enriched = new VectorStoreService.DocumentRecord( - doc.getId(), - doc.getContent(), - doc.getMetadata(), - embedding - ); - vectorStoreService.upsert(resolvedTenant, domainId, List.of(enriched)); - } - } - - private AssistantProperties.DomainConfig findDomain(String domainId) { - Optional match = properties.getDomains().stream() - .filter(domain -> domain.getId().equals(domainId)) - .findFirst(); - return match.orElseThrow(() -> new IllegalArgumentException("Unknown domain: " + domainId)); - } - - private Map llmExtractParams(String question, AssistantProperties.DomainConfig domain) { - if (domain.getQuerySchema().getAllowedParams().isEmpty()) { - return Map.of(); - } - // 将可用参数范围明确给模型,减少无关字段与幻觉。 - String prompt = "Extract query parameters as JSON using ONLY the allowed fields. " - + "If a field is not mentioned, omit it. Return JSON only.\n\n" - + "Allowed fields: " + domain.getQuerySchema().getAllowedParams() + "\n" - + "Enums: " + domain.getQuerySchema().getEnums() + "\n\n" - + "Question:\n" + question; - Prompt chatPrompt = new Prompt(List.of(new UserMessage(prompt))); - String content = chatModel.call(chatPrompt).getResult().getOutput().getContent(); - try { - Map raw = objectMapper.readValue(content, Map.class); - Map filtered = new HashMap<>(); - for (String key : domain.getQuerySchema().getAllowedParams()) { - if (raw.containsKey(key)) { - filtered.put(key, raw.get(key)); - } - } - return filtered; - } catch (Exception ex) { - return Map.of(); - } - } - - private Map normalizeParams( - Map params, - AssistantProperties.DomainConfig domain, - String question - ) { - // 对抽取参数进行校验并补充时间范围,避免非法值进入业务 API。 - Map normalized = new HashMap<>(); - Map> enums = domain.getQuerySchema().getEnums(); - for (Map.Entry entry : params.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - if (value == null) { - continue; - } - if (enums.containsKey(key)) { - List allowed = enums.get(key); - if (value instanceof List) { - List filtered = new ArrayList<>(); - for (Object item : (List) value) { - if (allowed.contains(String.valueOf(item))) { - filtered.add(String.valueOf(item)); - } - } - if (!filtered.isEmpty()) { - normalized.put(key, String.join(",", filtered)); - } - } else if (allowed.contains(String.valueOf(value))) { - normalized.put(key, String.valueOf(value)); - } - continue; - } - if ("time_range".equals(key) && value instanceof Map) { - Map range = (Map) value; - Object from = range.get("from"); - Object to = range.get("to"); - if (from != null && to != null) { - normalized.put("time_from", String.valueOf(from)); - normalized.put("time_to", String.valueOf(to)); - } - continue; - } - normalized.put(key, String.valueOf(value)); - } - if (!normalized.containsKey("time_from") || !normalized.containsKey("time_to")) { - Map resolved = TimeRangeParser.resolve(question); - if (resolved.containsKey("time_from") && resolved.containsKey("time_to")) { - normalized.putIfAbsent("time_from", resolved.get("time_from")); - normalized.putIfAbsent("time_to", resolved.get("time_to")); - } - } - return normalized; - } - - private List toContextRecords( - List> records, - String tenantId, - AssistantProperties.DomainConfig domain - ) { - List result = new ArrayList<>(); - for (Map record : records) { - String content = renderRecord(record, domain); - Map metadata = new HashMap<>(); - metadata.put("tenant_id", tenantId); - metadata.put("domain_id", domain.getId()); - metadata.put("source", domain.getId()); - metadata.put("record_id", recordId(record)); - result.add(new VectorStoreService.DocumentRecord(recordId(record), content, metadata, new float[0])); - } - return result; - } - - private String renderRecord(Map record, AssistantProperties.DomainConfig domain) { - List fields = domain.getFields(); - if (fields.isEmpty()) { - return record.toString(); - } - StringBuilder builder = new StringBuilder(); - for (String field : fields) { - Object value = record.get(field); - if (value == null) { - continue; - } - String displayValue = String.valueOf(value); - if ("status".equals(field) && domain.getStatusMappings().containsKey(displayValue)) { - displayValue = displayValue + " (" + domain.getStatusMappings().get(displayValue) + ")"; - } - builder.append(field).append(": ").append(displayValue).append("\n"); - } - return builder.toString().trim(); - } - - private String recordId(Map record) { - Object id = record.get("id"); - if (id == null) { - id = record.get("record_id"); - } - if (id == null) { - id = record.get("sku"); - } - if (id == null) { - id = record.get("order_no"); - } - if (id == null) { - id = record.get("return_no"); - } - return id == null ? "unknown" : String.valueOf(id); - } - private String buildContextText(List records) { StringBuilder builder = new StringBuilder(); for (VectorStoreService.DocumentRecord record : records) { Map meta = record.getMetadata(); - builder.append("source: ").append(meta.get("source")) - .append(" record_id: ").append(meta.get("record_id")) + builder.append("source: ").append(meta.get("domain_id")) .append("\n").append(record.getContent()).append("\n\n"); } return builder.toString().trim(); @@ -248,24 +81,12 @@ public class AssistantService { return chatModel.call(prompt).getResult().getOutput().getContent(); } - private ContextResult buildContext(String tenantId, String question) { - String resolvedTenant = tenantId == null || tenantId.isEmpty() ? properties.getDefaultTenantId() : tenantId; + private ContextResult buildContext(String question) { RouteResult routeResult = domainRouter.route(question); - AssistantProperties.DomainConfig domain = findDomain(routeResult.getDomainId()); - Map extractedParams = new HashMap<>(); - List contextRecords = new ArrayList<>(); - - if ("live".equalsIgnoreCase(domain.getQueryMode())) { - extractedParams = llmExtractParams(question, domain); - Map normalized = normalizeParams(extractedParams, domain, question); - List> records = domainFetcher.fetchRecords(domain, resolvedTenant, question, normalized); - contextRecords = toContextRecords(records, resolvedTenant, domain); - if (contextRecords.isEmpty()) { - contextRecords = vectorStoreService.similaritySearch(resolvedTenant, domain.getId(), question, 4); - } - } else { - contextRecords = vectorStoreService.similaritySearch(resolvedTenant, domain.getId(), question, 4); - } + String domainId = routeResult.getDomainId(); + + List contextRecords = + vectorStoreService.similaritySearch(domainId, question, 4); String contextText = buildContextText(contextRecords); List> sources = new ArrayList<>(); @@ -273,11 +94,10 @@ public class AssistantService { sources.add(record.getMetadata()); } return new ContextResult( - domain.getId(), + domainId, routeResult.getReason(), contextText, - sources, - extractedParams + sources ); } @@ -286,20 +106,17 @@ public class AssistantService { private final String routeReason; private final String contextText; private final List> sources; - private final Map params; private ContextResult( String domainId, String routeReason, String contextText, - List> sources, - Map params + List> sources ) { this.domainId = domainId; this.routeReason = routeReason; this.contextText = contextText; this.sources = sources; - this.params = params; } public String getDomainId() { @@ -317,9 +134,5 @@ public class AssistantService { public List> getSources() { return sources; } - - public Map getParams() { - return params; - } } } diff --git a/src/main/java/cn/kazusa/ai/assistant/service/DomainFetcher.java b/src/main/java/cn/kazusa/ai/assistant/service/DomainFetcher.java deleted file mode 100644 index bc9eded..0000000 --- a/src/main/java/cn/kazusa/ai/assistant/service/DomainFetcher.java +++ /dev/null @@ -1,205 +0,0 @@ -package cn.kazusa.ai.assistant.service; - -import cn.kazusa.ai.assistant.config.AssistantProperties; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Locale; -import java.util.concurrent.TimeUnit; -import feign.Request; -import feign.Response; -import feign.okhttp.OkHttpClient; -import org.springframework.core.io.Resource; -import org.springframework.core.io.ResourceLoader; -import org.springframework.stereotype.Component; - -/** - * @author zouzhiwen - */ -@Component -public class DomainFetcher { - private final OkHttpClient client; - private final ObjectMapper objectMapper; - private final ResourceLoader resourceLoader; - - public DomainFetcher(ObjectMapper objectMapper, ResourceLoader resourceLoader) { - this.client = new OkHttpClient(); - this.objectMapper = objectMapper; - this.resourceLoader = resourceLoader; - } - - public List> fetchRecords( - AssistantProperties.DomainConfig domain, - String tenantId, - String question, - Map params - ) { - // 每个业务域允许配置多个数据源,按顺序汇总。 - List> result = new ArrayList<>(); - for (AssistantProperties.DomainSource source : domain.getSources()) { - if ("jsonl".equalsIgnoreCase(source.getType())) { - result.addAll(loadJsonl(source.getPath())); - } else if ("api".equalsIgnoreCase(source.getType())) { - result.addAll(loadApi(source, tenantId, question, params)); - } - } - return result; - } - - private List> loadJsonl(String path) { - if (path == null || path.isEmpty()) { - return List.of(); - } - Resource resource = resourceLoader.getResource(path); - if (!resource.exists()) { - resource = resourceLoader.getResource("classpath:" + path); - } - List> items = new ArrayList<>(); - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - String trimmed = line.trim(); - if (trimmed.isEmpty()) { - continue; - } - items.add(objectMapper.readValue(trimmed, Map.class)); - } - } catch (Exception ex) { - return List.of(); - } - return items; - } - - private List> loadApi( - AssistantProperties.DomainSource source, - String tenantId, - String question, - Map params - ) { - Map replacements = new HashMap<>(); - replacements.put("tenant_id", tenantId); - replacements.put("question", question); - if (params != null) { - replacements.putAll(params); - } - String url = replacePlaceholders(source.getUrl(), replacements); - Map body = replacePlaceholders(source.getBody(), replacements); - body = stripUnresolved(body); - Request.HttpMethod method = Request.HttpMethod.valueOf(source.getMethod().toUpperCase(Locale.ROOT)); - byte[] bodyBytes = method == Request.HttpMethod.GET ? null : serializeBody(body); - Map> headers = new HashMap<>(); - for (Map.Entry header : source.getHeaders().entrySet()) { - headers.put(header.getKey(), List.of(header.getValue())); - } - Request request = Request.create(method, url, headers, bodyBytes, StandardCharsets.UTF_8, null); - try (Response response = client.execute(request, new Request.Options(30, TimeUnit.SECONDS, 30, TimeUnit.SECONDS, true))) { - if (response.status() >= 400 || response.body() == null) { - return List.of(); - } - JsonNode json = objectMapper.readTree(response.body().asInputStream()); - return extractItems(json, source.getItemsPath()); - } catch (Exception ex) { - return List.of(); - } - } - - private String replacePlaceholders(String template, Map replacements) { - if (template == null) { - return ""; - } - String result = template; - for (Map.Entry entry : replacements.entrySet()) { - result = result.replace("{" + entry.getKey() + "}", entry.getValue()); - } - return result; - } - - private Map replacePlaceholders(Map template, Map replacements) { - Map result = new HashMap<>(); - for (Map.Entry entry : template.entrySet()) { - result.put(entry.getKey(), replaceValue(entry.getValue(), replacements)); - } - return result; - } - - private Object replaceValue(Object value, Map replacements) { - if (value instanceof String) { - return replacePlaceholders((String) value, replacements); - } - if (value instanceof Map) { - Map nested = new HashMap<>(); - Map input = (Map) value; - for (Map.Entry entry : input.entrySet()) { - nested.put(String.valueOf(entry.getKey()), replaceValue(entry.getValue(), replacements)); - } - return nested; - } - if (value instanceof List) { - List nested = new ArrayList<>(); - for (Object item : (List) value) { - nested.add(replaceValue(item, replacements)); - } - return nested; - } - return value; - } - - private Map stripUnresolved(Map body) { - Map cleaned = new HashMap<>(); - for (Map.Entry entry : body.entrySet()) { - Object value = entry.getValue(); - if (value instanceof String && ((String) value).contains("{") && ((String) value).contains("}")) { - continue; - } - if (value instanceof Map) { - Map nested = stripUnresolved((Map) value); - if (!nested.isEmpty()) { - cleaned.put(entry.getKey(), nested); - } - continue; - } - cleaned.put(entry.getKey(), value); - } - return cleaned; - } - - private byte[] serializeBody(Map body) { - try { - return objectMapper.writeValueAsBytes(body); - } catch (Exception ex) { - return "{}".getBytes(StandardCharsets.UTF_8); - } - } - - private List> extractItems(JsonNode data, String itemsPath) { - if (data == null || data.isNull()) { - return List.of(); - } - JsonNode current = data; - if (itemsPath != null && !itemsPath.isEmpty()) { - String[] parts = itemsPath.split("\\."); - for (String part : parts) { - if (part.isEmpty()) { - continue; - } - current = current.path(part); - } - } - if (current == null || !current.isArray()) { - return List.of(); - } - List> items = new ArrayList<>(); - for (JsonNode node : current) { - items.add(objectMapper.convertValue(node, Map.class)); - } - return items; - } -} diff --git a/src/main/java/cn/kazusa/ai/assistant/service/DomainRouter.java b/src/main/java/cn/kazusa/ai/assistant/service/DomainRouter.java index 33f8537..f0fda21 100644 --- a/src/main/java/cn/kazusa/ai/assistant/service/DomainRouter.java +++ b/src/main/java/cn/kazusa/ai/assistant/service/DomainRouter.java @@ -1,7 +1,6 @@ package cn.kazusa.ai.assistant.service; import cn.kazusa.ai.assistant.config.AssistantProperties; -import cn.kazusa.ai.assistant.model.AssistantAnswer; import java.util.List; import java.util.Locale; import java.util.Optional; diff --git a/src/main/java/cn/kazusa/ai/assistant/service/TimeRangeParser.java b/src/main/java/cn/kazusa/ai/assistant/service/TimeRangeParser.java deleted file mode 100644 index 0f255ab..0000000 --- a/src/main/java/cn/kazusa/ai/assistant/service/TimeRangeParser.java +++ /dev/null @@ -1,70 +0,0 @@ -package cn.kazusa.ai.assistant.service; - -import java.time.DayOfWeek; -import java.time.LocalDate; -import java.time.temporal.TemporalAdjusters; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; - -/** - * @author zouzhiwen - */ -public final class TimeRangeParser { - private TimeRangeParser() { - } - - public static Map resolve(String question) { - // 只处理常见中文相对时间表达,无法识别时返回空。 - Map result = new HashMap<>(); - if (question == null || question.isEmpty()) { - return result; - } - String q = question.replace(" ", ""); - LocalDate today = LocalDate.now(); - if (q.contains("近一周") || q.contains("最近一周") || q.contains("过去一周")) { - return range(today.minusDays(7), today); - } - if (q.contains("近一个月") || q.contains("最近一个月") || q.contains("过去一个月")) { - return range(today.minusDays(30), today); - } - if (q.contains("近30天") || q.contains("最近30天") || q.contains("过去30天")) { - return range(today.minusDays(30), today); - } - if (q.contains("本周")) { - LocalDate start = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - return range(start, today); - } - if (q.contains("上周")) { - LocalDate start = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).minusWeeks(1); - LocalDate end = start.plusDays(6); - return range(start, end); - } - if (q.contains("本月")) { - LocalDate start = today.withDayOfMonth(1); - return range(start, today); - } - if (q.contains("上月")) { - LocalDate start = today.minusMonths(1).withDayOfMonth(1); - LocalDate end = start.plusMonths(1).minusDays(1); - return range(start, end); - } - if (q.contains("今年")) { - LocalDate start = LocalDate.of(today.getYear(), 1, 1); - return range(start, today); - } - if (q.contains("去年")) { - LocalDate start = LocalDate.of(today.getYear() - 1, 1, 1); - LocalDate end = LocalDate.of(today.getYear() - 1, 12, 31); - return range(start, end); - } - return result; - } - - private static Map range(LocalDate from, LocalDate to) { - Map result = new HashMap<>(); - result.put("time_from", from.toString()); - result.put("time_to", to.toString()); - return result; - } -} diff --git a/src/main/java/cn/kazusa/ai/assistant/service/VectorStoreService.java b/src/main/java/cn/kazusa/ai/assistant/service/VectorStoreService.java index 39479a0..9ab2bd3 100644 --- a/src/main/java/cn/kazusa/ai/assistant/service/VectorStoreService.java +++ b/src/main/java/cn/kazusa/ai/assistant/service/VectorStoreService.java @@ -38,11 +38,10 @@ public class VectorStoreService { initSchema(); } - public void upsert(String tenantId, String domainId, List records) { + public void upsert(String domainId, List records) { for (DocumentRecord record : records) { VectorDocument doc = new VectorDocument(); doc.setId(record.getId()); - doc.setTenantId(tenantId); doc.setDomainId(domainId); doc.setContent(record.getContent()); try { @@ -61,10 +60,10 @@ public class VectorStoreService { } } - public List similaritySearch(String tenantId, String domainId, String query, int topK) { + public List similaritySearch(String domainId, String query, int topK) { float[] embedding = embeddingModel.embed(query); String embeddingStr = arrayToString(embedding); - List docs = vectorDocumentMapper.similaritySearch(tenantId, domainId, embeddingStr, topK); + List docs = vectorDocumentMapper.similaritySearch(domainId, embeddingStr, topK); List result = new ArrayList<>(); for (VectorDocument doc : docs) { Map metadata; @@ -101,13 +100,12 @@ public class VectorStoreService { jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS vector"); jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS " + table + " (" + "id TEXT PRIMARY KEY," - + "tenant_id TEXT NOT NULL," + "domain_id TEXT NOT NULL," + "content TEXT NOT NULL," + "metadata JSONB NOT NULL," + "embedding vector(" + dimension + ")" + ")"); - jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_" + table + "_tenant_domain ON " + table + " (tenant_id, domain_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_" + table + "_domain ON " + table + " (domain_id)"); } public static class DocumentRecord { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 478d801..b71b667 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,8 +2,6 @@ server: port: 8010 spring: - config: - import: classpath:domains.yml datasource: url: jdbc:postgresql://localhost:5432/assistant username: postgres @@ -19,10 +17,16 @@ spring: model: bge-m3 assistant: - default-tenant-id: customer_a routing: use-llm: true - fallback-domain: inventory + fallback-domain: general vector: table: assistant_vectors dimension: 1024 + domains: + - id: general + name: 通用知识 + keywords: [] + - id: allocation + name: 调拨知识 + keywords: [] diff --git a/src/main/resources/data/sample_inventory_customer_a.jsonl b/src/main/resources/data/sample_inventory_customer_a.jsonl deleted file mode 100644 index e311870..0000000 --- a/src/main/resources/data/sample_inventory_customer_a.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"owner":"CUST_A","warehouse":"WH01","sku":"SKU-1001","qty":120,"location":"A01-01-01","lot":"LOT-202501","status":"AVAILABLE","updated_at":"2025-01-20 10:00:00"} -{"owner":"CUST_A","warehouse":"WH01","sku":"SKU-2002","qty":45,"location":"B02-03-04","lot":"LOT-202502","status":"HOLD","updated_at":"2025-01-20 11:30:00"} diff --git a/src/main/resources/data/sample_orders_customer_a.jsonl b/src/main/resources/data/sample_orders_customer_a.jsonl deleted file mode 100644 index f1a6421..0000000 --- a/src/main/resources/data/sample_orders_customer_a.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"order_no":"SO-10001","owner":"CUST_A","warehouse":"WH01","status":"CREATED","qty":12,"created_at":"2025-01-18 09:10:00"} -{"order_no":"SO-10002","owner":"CUST_A","warehouse":"WH01","status":"SHIPPED","qty":5,"created_at":"2025-01-19 16:40:00"} diff --git a/src/main/resources/data/sample_returns_customer_a.jsonl b/src/main/resources/data/sample_returns_customer_a.jsonl deleted file mode 100644 index 9bfc879..0000000 --- a/src/main/resources/data/sample_returns_customer_a.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"return_no":"RT-9001","owner":"CUST_A","warehouse":"WH01","status":"RECEIVED","qty":2,"reason":"damaged","created_at":"2025-01-20 15:25:00"} -{"return_no":"RT-9002","owner":"CUST_A","warehouse":"WH01","status":"PENDING","qty":1,"reason":"wrong_item","created_at":"2025-01-21 10:05:00"} diff --git a/src/main/resources/domains.yml b/src/main/resources/domains.yml deleted file mode 100644 index c89d0f2..0000000 --- a/src/main/resources/domains.yml +++ /dev/null @@ -1,73 +0,0 @@ -assistant: - domains: - - id: inventory - name: Inventory - query-mode: live - keywords: ["库存", "库存量", "库位", "库存查询", "qty", "stock"] - fields: ["owner", "warehouse", "sku", "qty", "location", "lot", "status", "updated_at"] - sources: - - type: jsonl - path: classpath:data/sample_inventory_customer_a.jsonl - - type: api - url: http://127.0.0.1:8080/internal/inventory/query - method: POST - headers: - Authorization: Bearer REPLACE_ME - body: - tenant_id: "{tenant_id}" - query: "{question}" - items-path: data.items - - id: orders - name: Orders - query-mode: live - keywords: ["订单", "出库", "发货", "order", "shipment"] - fields: ["order_no", "owner", "warehouse", "status", "qty", "created_at"] - status-mappings: - CREATED: "已创建" - PICKING: "拣货中" - PACKED: "已打包" - SHIPPED: "已发货" - CANCELLED: "已取消" - query-schema: - allowed-params: ["time_range", "status", "owner", "warehouse", "metric"] - enums: - status: ["CREATED", "PICKING", "PACKED", "SHIPPED", "CANCELLED"] - sources: - - type: jsonl - path: classpath:data/sample_orders_customer_a.jsonl - - type: api - url: http://127.0.0.1:8080/internal/orders/query - method: POST - headers: - Authorization: Bearer REPLACE_ME - body: - tenant_id: "{tenant_id}" - query: "{question}" - time_from: "{time_from}" - time_to: "{time_to}" - status: "{status}" - items-path: data.items - - id: returns - name: Returns - query-mode: live - keywords: ["退货", "退单", "return", "refund"] - fields: ["return_no", "owner", "warehouse", "status", "qty", "reason", "created_at"] - query-schema: - allowed-params: ["time_range", "status", "owner", "warehouse", "reason"] - enums: - status: ["PENDING", "RECEIVED", "REJECTED"] - sources: - - type: jsonl - path: classpath:data/sample_returns_customer_a.jsonl - - type: api - url: http://127.0.0.1:8080/internal/returns/query - method: POST - headers: - Authorization: Bearer REPLACE_ME - body: - tenant_id: "{tenant_id}" - query: "{question}" - time_from: "{time_from}" - time_to: "{time_to}" - status: "{status}" - items-path: data.items diff --git a/src/main/resources/mapper/VectorDocumentMapper.xml b/src/main/resources/mapper/VectorDocumentMapper.xml index bf68703..bff5c15 100644 --- a/src/main/resources/mapper/VectorDocumentMapper.xml +++ b/src/main/resources/mapper/VectorDocumentMapper.xml @@ -4,10 +4,9 @@