ref: 知识库生成

This commit is contained in:
kazusa 2026-01-07 20:39:26 +08:00
parent 966c269681
commit fffa431dde
17 changed files with 60 additions and 740 deletions

View File

@ -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;
}
}
}

View File

@ -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));
}
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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

View File

@ -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() {

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -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: []

View File

@ -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"}

View File

@ -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"}

View File

@ -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"}

View File

@ -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

View File

@ -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}
]]>