ref: 知识库生成
This commit is contained in:
parent
966c269681
commit
fffa431dde
|
|
@ -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<DomainConfig> 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<String> keywords = new ArrayList<>();
|
||||
private List<String> fields = new ArrayList<>();
|
||||
private Map<String, String> statusMappings = new HashMap<>();
|
||||
private QuerySchema querySchema = new QuerySchema();
|
||||
private List<DomainSource> 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<String> getKeywords() {
|
||||
return keywords;
|
||||
}
|
||||
|
|
@ -123,124 +99,5 @@ public class AssistantProperties {
|
|||
public void setKeywords(List<String> keywords) {
|
||||
this.keywords = keywords;
|
||||
}
|
||||
|
||||
public List<String> getFields() {
|
||||
return fields;
|
||||
}
|
||||
|
||||
public void setFields(List<String> fields) {
|
||||
this.fields = fields;
|
||||
}
|
||||
|
||||
public Map<String, String> getStatusMappings() {
|
||||
return statusMappings;
|
||||
}
|
||||
|
||||
public void setStatusMappings(Map<String, String> statusMappings) {
|
||||
this.statusMappings = statusMappings;
|
||||
}
|
||||
|
||||
public QuerySchema getQuerySchema() {
|
||||
return querySchema;
|
||||
}
|
||||
|
||||
public void setQuerySchema(QuerySchema querySchema) {
|
||||
this.querySchema = querySchema;
|
||||
}
|
||||
|
||||
public List<DomainSource> getSources() {
|
||||
return sources;
|
||||
}
|
||||
|
||||
public void setSources(List<DomainSource> sources) {
|
||||
this.sources = sources;
|
||||
}
|
||||
}
|
||||
|
||||
public static class QuerySchema {
|
||||
private List<String> allowedParams = new ArrayList<>();
|
||||
private Map<String, List<String>> enums = new HashMap<>();
|
||||
|
||||
public List<String> getAllowedParams() {
|
||||
return allowedParams;
|
||||
}
|
||||
|
||||
public void setAllowedParams(List<String> allowedParams) {
|
||||
this.allowedParams = allowedParams;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> getEnums() {
|
||||
return enums;
|
||||
}
|
||||
|
||||
public void setEnums(Map<String, List<String>> 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<String, String> headers = new HashMap<>();
|
||||
private Map<String, Object> 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<String, String> getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public void setHeaders(Map<String, String> headers) {
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
public Map<String, Object> getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public void setBody(Map<String, Object> body) {
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String> 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import java.util.List;
|
|||
public interface VectorDocumentMapper extends BaseMapper<VectorDocument> {
|
||||
|
||||
List<VectorDocument> similaritySearch(
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("domainId") String domainId,
|
||||
@Param("embedding") String embedding,
|
||||
@Param("topK") int topK
|
||||
|
|
|
|||
|
|
@ -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<ChatMessage> 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<ChatMessage> getMessages() {
|
||||
|
|
|
|||
|
|
@ -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<String> onToken) {
|
||||
ContextResult contextResult = buildContext(tenantId, question);
|
||||
public void streamAnswer(String customerId, String question, java.util.function.Consumer<String> 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<Map<String, Object>> records = domainFetcher.fetchRecords(domain, resolvedTenant, "", Map.of());
|
||||
List<VectorStoreService.DocumentRecord> 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<AssistantProperties.DomainConfig> match = properties.getDomains().stream()
|
||||
.filter(domain -> domain.getId().equals(domainId))
|
||||
.findFirst();
|
||||
return match.orElseThrow(() -> new IllegalArgumentException("Unknown domain: " + domainId));
|
||||
}
|
||||
|
||||
private Map<String, Object> 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<String, Object> raw = objectMapper.readValue(content, Map.class);
|
||||
Map<String, Object> 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<String, String> normalizeParams(
|
||||
Map<String, Object> params,
|
||||
AssistantProperties.DomainConfig domain,
|
||||
String question
|
||||
) {
|
||||
// 对抽取参数进行校验并补充时间范围,避免非法值进入业务 API。
|
||||
Map<String, String> normalized = new HashMap<>();
|
||||
Map<String, List<String>> enums = domain.getQuerySchema().getEnums();
|
||||
for (Map.Entry<String, Object> entry : params.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (enums.containsKey(key)) {
|
||||
List<String> allowed = enums.get(key);
|
||||
if (value instanceof List) {
|
||||
List<String> 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<String, String> 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<VectorStoreService.DocumentRecord> toContextRecords(
|
||||
List<Map<String, Object>> records,
|
||||
String tenantId,
|
||||
AssistantProperties.DomainConfig domain
|
||||
) {
|
||||
List<VectorStoreService.DocumentRecord> result = new ArrayList<>();
|
||||
for (Map<String, Object> record : records) {
|
||||
String content = renderRecord(record, domain);
|
||||
Map<String, Object> 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<String, Object> record, AssistantProperties.DomainConfig domain) {
|
||||
List<String> 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<String, Object> 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<VectorStoreService.DocumentRecord> records) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (VectorStoreService.DocumentRecord record : records) {
|
||||
Map<String, Object> 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<String, Object> extractedParams = new HashMap<>();
|
||||
List<VectorStoreService.DocumentRecord> contextRecords = new ArrayList<>();
|
||||
|
||||
if ("live".equalsIgnoreCase(domain.getQueryMode())) {
|
||||
extractedParams = llmExtractParams(question, domain);
|
||||
Map<String, String> normalized = normalizeParams(extractedParams, domain, question);
|
||||
List<Map<String, Object>> 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<VectorStoreService.DocumentRecord> contextRecords =
|
||||
vectorStoreService.similaritySearch(domainId, question, 4);
|
||||
|
||||
String contextText = buildContextText(contextRecords);
|
||||
List<Map<String, Object>> 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<Map<String, Object>> sources;
|
||||
private final Map<String, Object> params;
|
||||
|
||||
private ContextResult(
|
||||
String domainId,
|
||||
String routeReason,
|
||||
String contextText,
|
||||
List<Map<String, Object>> sources,
|
||||
Map<String, Object> params
|
||||
List<Map<String, Object>> 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<Map<String, Object>> getSources() {
|
||||
return sources;
|
||||
}
|
||||
|
||||
public Map<String, Object> getParams() {
|
||||
return params;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Object>> fetchRecords(
|
||||
AssistantProperties.DomainConfig domain,
|
||||
String tenantId,
|
||||
String question,
|
||||
Map<String, String> params
|
||||
) {
|
||||
// 每个业务域允许配置多个数据源,按顺序汇总。
|
||||
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> loadApi(
|
||||
AssistantProperties.DomainSource source,
|
||||
String tenantId,
|
||||
String question,
|
||||
Map<String, String> params
|
||||
) {
|
||||
Map<String, String> 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<String, Object> 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<String, Collection<String>> headers = new HashMap<>();
|
||||
for (Map.Entry<String, String> 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<String, String> replacements) {
|
||||
if (template == null) {
|
||||
return "";
|
||||
}
|
||||
String result = template;
|
||||
for (Map.Entry<String, String> entry : replacements.entrySet()) {
|
||||
result = result.replace("{" + entry.getKey() + "}", entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> replacePlaceholders(Map<String, Object> template, Map<String, String> replacements) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
for (Map.Entry<String, Object> entry : template.entrySet()) {
|
||||
result.put(entry.getKey(), replaceValue(entry.getValue(), replacements));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Object replaceValue(Object value, Map<String, String> replacements) {
|
||||
if (value instanceof String) {
|
||||
return replacePlaceholders((String) value, replacements);
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
Map<String, Object> 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<Object> nested = new ArrayList<>();
|
||||
for (Object item : (List<?>) value) {
|
||||
nested.add(replaceValue(item, replacements));
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private Map<String, Object> stripUnresolved(Map<String, Object> body) {
|
||||
Map<String, Object> cleaned = new HashMap<>();
|
||||
for (Map.Entry<String, Object> entry : body.entrySet()) {
|
||||
Object value = entry.getValue();
|
||||
if (value instanceof String && ((String) value).contains("{") && ((String) value).contains("}")) {
|
||||
continue;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
Map<String, Object> nested = stripUnresolved((Map<String, Object>) value);
|
||||
if (!nested.isEmpty()) {
|
||||
cleaned.put(entry.getKey(), nested);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cleaned.put(entry.getKey(), value);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private byte[] serializeBody(Map<String, Object> body) {
|
||||
try {
|
||||
return objectMapper.writeValueAsBytes(body);
|
||||
} catch (Exception ex) {
|
||||
return "{}".getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> 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<Map<String, Object>> items = new ArrayList<>();
|
||||
for (JsonNode node : current) {
|
||||
items.add(objectMapper.convertValue(node, Map.class));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String, String> resolve(String question) {
|
||||
// 只处理常见中文相对时间表达,无法识别时返回空。
|
||||
Map<String, String> 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<String, String> range(LocalDate from, LocalDate to) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("time_from", from.toString());
|
||||
result.put("time_to", to.toString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,11 +38,10 @@ public class VectorStoreService {
|
|||
initSchema();
|
||||
}
|
||||
|
||||
public void upsert(String tenantId, String domainId, List<DocumentRecord> records) {
|
||||
public void upsert(String domainId, List<DocumentRecord> 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<DocumentRecord> similaritySearch(String tenantId, String domainId, String query, int topK) {
|
||||
public List<DocumentRecord> similaritySearch(String domainId, String query, int topK) {
|
||||
float[] embedding = embeddingModel.embed(query);
|
||||
String embeddingStr = arrayToString(embedding);
|
||||
List<VectorDocument> docs = vectorDocumentMapper.similaritySearch(tenantId, domainId, embeddingStr, topK);
|
||||
List<VectorDocument> docs = vectorDocumentMapper.similaritySearch(domainId, embeddingStr, topK);
|
||||
List<DocumentRecord> result = new ArrayList<>();
|
||||
for (VectorDocument doc : docs) {
|
||||
Map<String, Object> 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 {
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -4,10 +4,9 @@
|
|||
|
||||
<select id="similaritySearch" resultType="cn.kazusa.ai.assistant.entity.VectorDocument">
|
||||
<![CDATA[
|
||||
SELECT id, tenant_id, domain_id, content, metadata, embedding
|
||||
SELECT id, domain_id, content, metadata, embedding
|
||||
FROM assistant_vectors
|
||||
WHERE tenant_id = #{tenantId}
|
||||
AND domain_id = #{domainId}
|
||||
WHERE domain_id = #{domainId}
|
||||
ORDER BY embedding <=> #{embedding}::vector
|
||||
LIMIT #{topK}
|
||||
]]>
|
||||
|
|
|
|||
Loading…
Reference in New Issue