This commit is contained in:
kazusa 2026-01-07 18:55:13 +08:00
commit 966c269681
25 changed files with 2031 additions and 0 deletions

53
README.md Normal file
View File

@ -0,0 +1,53 @@
# AI Assistant Java (Spring AI + pgvector)
This module is an independent Spring Boot 3 service that provides an OpenAI-compatible API.
## Requirements
- Java 17+
- PostgreSQL with pgvector
- Ollama (local inference)
## Start pgvector (Docker)
```
docker run --name pgvector -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=assistant \
-p 5432:5432 -d pgvector/pgvector:pg16
```
## Start Ollama models
```
ollama pull qwen2.5:7b
ollama pull bge-m3
```
## Configure
- `src/main/resources/application.yml`
- `src/main/resources/domains.yml`
## Run
```
./mvnw spring-boot:run
```
## OpenAI-compatible endpoint
```
POST http://127.0.0.1:8010/v1/chat/completions
```
Sample payload:
```
{
"model": "local",
"tenant_id": "customer_a",
"stream": true,
"messages": [
{"role": "user", "content": "订单有多少?"}
]
}
```

97
pom.xml Normal file
View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>cn.kazusa.ai</groupId>
<artifactId>assistant</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>ai-assistant-java</name>
<description>Independent AI assistant service</description>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M5</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
<version>13.2</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
<version>13.2</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,18 @@
package cn.kazusa.ai.assistant;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
/**
* @author zouzhiwen
*/
@SpringBootApplication
@ConfigurationPropertiesScan
@MapperScan("cn.kazusa.ai.assistant.mapper")
public class AssistantApplication {
public static void main(String[] args) {
SpringApplication.run(AssistantApplication.class, args);
}
}

View File

@ -0,0 +1,246 @@
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;
/**
* @author zouzhiwen
*/
@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;
}
public Vector getVector() {
return vector;
}
public List<DomainConfig> getDomains() {
return domains;
}
public void setDomains(List<DomainConfig> domains) {
this.domains = domains;
}
public static class Routing {
private boolean useLlm = true;
private String fallbackDomain = "inventory";
public boolean isUseLlm() {
return useLlm;
}
public void setUseLlm(boolean useLlm) {
this.useLlm = useLlm;
}
public String getFallbackDomain() {
return fallbackDomain;
}
public void setFallbackDomain(String fallbackDomain) {
this.fallbackDomain = fallbackDomain;
}
}
public static class Vector {
private String table = "assistant_vectors";
private int dimension = 1024;
public String getTable() {
return table;
}
public void setTable(String table) {
this.table = table;
}
public int getDimension() {
return dimension;
}
public void setDimension(int dimension) {
this.dimension = dimension;
}
}
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;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getQueryMode() {
return queryMode;
}
public void setQueryMode(String queryMode) {
this.queryMode = queryMode;
}
public List<String> getKeywords() {
return keywords;
}
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

@ -0,0 +1,33 @@
package cn.kazusa.ai.assistant.controller;
import cn.kazusa.ai.assistant.service.AssistantService;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author zouzhiwen
*/
@RestController
@RequestMapping("/ingest")
public class IngestController {
private final AssistantService assistantService;
public IngestController(AssistantService assistantService) {
this.assistantService = assistantService;
}
@PostMapping
public ResponseEntity<?> ingest(@RequestBody Map<String, String> payload) {
String tenantId = payload.get("tenant_id");
String domainId = payload.get("domain_id");
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));
}
}

View File

@ -0,0 +1,143 @@
package cn.kazusa.ai.assistant.controller;
import cn.kazusa.ai.assistant.model.AssistantAnswer;
import cn.kazusa.ai.assistant.model.ChatCompletionRequest;
import cn.kazusa.ai.assistant.model.ChatCompletionResponse;
import cn.kazusa.ai.assistant.model.ChatMessage;
import cn.kazusa.ai.assistant.service.AssistantService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.servlet.http.HttpServletResponse;
/**
* @author zouzhiwen
*/
@RestController
@RequestMapping("/v1")
@CrossOrigin
public class OpenAIController {
private final AssistantService assistantService;
private final ObjectMapper objectMapper;
public OpenAIController(AssistantService assistantService, ObjectMapper objectMapper) {
this.assistantService = assistantService;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/chat/completions")
public Object chat(@RequestBody ChatCompletionRequest request, HttpServletResponse servletResponse) {
String question = extractQuestion(request.getMessages());
if (question == null || question.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "user message required"));
}
if (Boolean.TRUE.equals(request.getStream())) {
servletResponse.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
return streamResponse(request, question);
}
AssistantAnswer answer = assistantService.answer(request.getTenantId(), question);
ChatCompletionResponse response = buildResponse(request.getModel(), answer.getAnswer());
Map<String, Object> meta = new HashMap<>();
meta.put("domain_id", answer.getDomainId());
meta.put("route_reason", answer.getRouteReason());
meta.put("sources", answer.getSources());
meta.put("context", answer.getContext());
meta.put("params", answer.getParams());
response.setMeta(meta);
return ResponseEntity.ok(response);
}
private StreamingResponseBody streamResponse(ChatCompletionRequest request, String question) {
return outputStream -> {
try {
writeEvent(outputStream, buildChunk("assistant", null, request.getModel(), true));
assistantService.streamAnswer(request.getTenantId(), question, token -> {
try {
writeEvent(outputStream, buildChunk(null, token, request.getModel(), true));
} catch (Exception ex) {
// 忽略单个分片的写入异常保持流式响应不中断
}
});
writeEvent(outputStream, buildChunk(null, null, request.getModel(), false));
writeEvent(outputStream, "[DONE]");
} catch (Exception ex) {
throw new RuntimeException("Stream write failed", ex);
}
};
}
private void writeEvent(OutputStream outputStream, Object payload) throws Exception {
String data = payload instanceof String ? (String) payload : objectMapper.writeValueAsString(payload);
String line = "data: " + data + "\n\n";
outputStream.write(line.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
}
private ChatCompletionResponse buildResponse(String model, String content) {
ChatCompletionResponse response = new ChatCompletionResponse();
response.setId("chatcmpl-local");
response.setObject("chat.completion");
response.setCreated(Instant.now().getEpochSecond());
response.setModel(model == null ? "local" : model);
ChatCompletionResponse.Choice choice = new ChatCompletionResponse.Choice();
choice.setIndex(0);
ChatMessage message = new ChatMessage();
message.setRole("assistant");
message.setContent(content);
choice.setMessage(message);
choice.setFinishReason("stop");
response.setChoices(List.of(choice));
ChatCompletionResponse.Usage usage = new ChatCompletionResponse.Usage();
usage.setPromptTokens(0);
usage.setCompletionTokens(0);
usage.setTotalTokens(0);
response.setUsage(usage);
return response;
}
private Map<String, Object> buildChunk(String role, String content, String model, boolean ongoing) {
Map<String, Object> response = new HashMap<>();
response.put("id", "chatcmpl-local");
response.put("object", "chat.completion.chunk");
response.put("created", Instant.now().getEpochSecond());
response.put("model", model == null ? "local" : model);
Map<String, Object> delta = new HashMap<>();
if (role != null) {
delta.put("role", role);
}
if (content != null) {
delta.put("content", content);
}
Map<String, Object> choice = new HashMap<>();
choice.put("index", 0);
choice.put("delta", delta);
choice.put("finish_reason", ongoing ? null : "stop");
response.put("choices", List.of(choice));
return response;
}
private String extractQuestion(List<ChatMessage> messages) {
if (messages == null || messages.isEmpty()) {
return null;
}
for (int i = messages.size() - 1; i >= 0; i--) {
ChatMessage message = messages.get(i);
if ("user".equalsIgnoreCase(message.getRole())) {
return message.getContent();
}
}
return null;
}
}

View File

@ -0,0 +1,71 @@
package cn.kazusa.ai.assistant.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import cn.kazusa.ai.assistant.typehandler.JsonbTypeHandler;
import cn.kazusa.ai.assistant.typehandler.VectorFloatTypeHandler;
/**
* @author zouzhiwen
*/
@TableName(value = "assistant_vectors", autoResultMap = true)
public class VectorDocument {
@TableId
private String id;
private String tenantId;
private String domainId;
private String content;
@TableField(typeHandler = JsonbTypeHandler.class)
private String metadata;
@TableField(typeHandler = VectorFloatTypeHandler.class)
private float[] embedding;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getTenantId() {
return tenantId;
}
public void setTenantId(String tenantId) {
this.tenantId = tenantId;
}
public String getDomainId() {
return domainId;
}
public void setDomainId(String domainId) {
this.domainId = domainId;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getMetadata() {
return metadata;
}
public void setMetadata(String metadata) {
this.metadata = metadata;
}
public float[] getEmbedding() {
return embedding;
}
public void setEmbedding(float[] embedding) {
this.embedding = embedding;
}
}

View File

@ -0,0 +1,22 @@
package cn.kazusa.ai.assistant.mapper;
import cn.kazusa.ai.assistant.entity.VectorDocument;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* @author zouzhiwen
*/
@Mapper
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

@ -0,0 +1,56 @@
package cn.kazusa.ai.assistant.model;
import java.util.List;
import java.util.Map;
/**
* @author zouzhiwen
*/
public class AssistantAnswer {
private final String answer;
private final List<Map<String, Object>> sources;
private final String context;
private final String domainId;
private final String routeReason;
private final Map<String, Object> params;
public AssistantAnswer(
String answer,
List<Map<String, Object>> sources,
String context,
String domainId,
String routeReason,
Map<String, Object> params
) {
this.answer = answer;
this.sources = sources;
this.context = context;
this.domainId = domainId;
this.routeReason = routeReason;
this.params = params;
}
public String getAnswer() {
return answer;
}
public List<Map<String, Object>> getSources() {
return sources;
}
public String getContext() {
return context;
}
public String getDomainId() {
return domainId;
}
public String getRouteReason() {
return routeReason;
}
public Map<String, Object> getParams() {
return params;
}
}

View File

@ -0,0 +1,46 @@
package cn.kazusa.ai.assistant.model;
import java.util.ArrayList;
import java.util.List;
/**
* @author zouzhiwen
*/
public class ChatCompletionRequest {
private String model;
private Boolean stream;
private String tenantId;
private List<ChatMessage> messages = new ArrayList<>();
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public Boolean getStream() {
return stream;
}
public void setStream(Boolean stream) {
this.stream = stream;
}
public String getTenantId() {
return tenantId;
}
public void setTenantId(String tenantId) {
this.tenantId = tenantId;
}
public List<ChatMessage> getMessages() {
return messages;
}
public void setMessages(List<ChatMessage> messages) {
this.messages = messages;
}
}

View File

@ -0,0 +1,136 @@
package cn.kazusa.ai.assistant.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* @author zouzhiwen
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ChatCompletionResponse {
private String id;
private String object;
private long created;
private String model;
private List<Choice> choices = new ArrayList<>();
private Usage usage;
private Map<String, Object> meta;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getObject() {
return object;
}
public void setObject(String object) {
this.object = object;
}
public long getCreated() {
return created;
}
public void setCreated(long created) {
this.created = created;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public List<Choice> getChoices() {
return choices;
}
public void setChoices(List<Choice> choices) {
this.choices = choices;
}
public Usage getUsage() {
return usage;
}
public void setUsage(Usage usage) {
this.usage = usage;
}
public Map<String, Object> getMeta() {
return meta;
}
public void setMeta(Map<String, Object> meta) {
this.meta = meta;
}
public static class Choice {
private int index;
private ChatMessage message;
private String finishReason;
public int getIndex() {
return index;
}
public void setIndex(int index) {
this.index = index;
}
public ChatMessage getMessage() {
return message;
}
public void setMessage(ChatMessage message) {
this.message = message;
}
public String getFinishReason() {
return finishReason;
}
public void setFinishReason(String finishReason) {
this.finishReason = finishReason;
}
}
public static class Usage {
private int promptTokens;
private int completionTokens;
private int totalTokens;
public int getPromptTokens() {
return promptTokens;
}
public void setPromptTokens(int promptTokens) {
this.promptTokens = promptTokens;
}
public int getCompletionTokens() {
return completionTokens;
}
public void setCompletionTokens(int completionTokens) {
this.completionTokens = completionTokens;
}
public int getTotalTokens() {
return totalTokens;
}
public void setTotalTokens(int totalTokens) {
this.totalTokens = totalTokens;
}
}
}

View File

@ -0,0 +1,25 @@
package cn.kazusa.ai.assistant.model;
/**
* @author zouzhiwen
*/
public class ChatMessage {
private String role;
private String content;
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}

View File

@ -0,0 +1,325 @@
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;
import org.springframework.stereotype.Service;
/**
* @author zouzhiwen
*/
@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
) {
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);
String answer = callChatModel(contextResult.getContextText(), question);
return new AssistantAnswer(
answer,
contextResult.getSources(),
contextResult.getContextText(),
contextResult.getDomainId(),
contextResult.getRouteReason(),
contextResult.getParams()
);
}
public void streamAnswer(String tenantId, String question, java.util.function.Consumer<String> onToken) {
ContextResult contextResult = buildContext(tenantId, question);
Prompt prompt = buildPrompt(contextResult.getContextText(), question);
chatModel.stream(prompt).toStream().forEach(response -> {
String token = response.getResult().getOutput().getContent();
if (token != null && !token.isEmpty()) {
onToken.accept(token);
}
});
}
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"))
.append("\n").append(record.getContent()).append("\n\n");
}
return builder.toString().trim();
}
private Prompt buildPrompt(String context, String question) {
String systemPrompt = "You are a warehouse assistant. Answer using ONLY the provided context. "
+ "If the answer is not in the context, say you do not have enough data. "
+ "Keep answers concise and factual.";
String userPrompt = "Context:\n" + context + "\n\nQuestion:\n" + question;
return new Prompt(List.of(new UserMessage(systemPrompt), new UserMessage(userPrompt)));
}
private String callChatModel(String context, String question) {
Prompt prompt = buildPrompt(context, question);
return chatModel.call(prompt).getResult().getOutput().getContent();
}
private ContextResult buildContext(String tenantId, String question) {
String resolvedTenant = tenantId == null || tenantId.isEmpty() ? properties.getDefaultTenantId() : tenantId;
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 contextText = buildContextText(contextRecords);
List<Map<String, Object>> sources = new ArrayList<>();
for (VectorStoreService.DocumentRecord record : contextRecords) {
sources.add(record.getMetadata());
}
return new ContextResult(
domain.getId(),
routeResult.getReason(),
contextText,
sources,
extractedParams
);
}
private static class ContextResult {
private final String domainId;
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
) {
this.domainId = domainId;
this.routeReason = routeReason;
this.contextText = contextText;
this.sources = sources;
this.params = params;
}
public String getDomainId() {
return domainId;
}
public String getRouteReason() {
return routeReason;
}
public String getContextText() {
return contextText;
}
public List<Map<String, Object>> getSources() {
return sources;
}
public Map<String, Object> getParams() {
return params;
}
}
}

View File

@ -0,0 +1,205 @@
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

@ -0,0 +1,106 @@
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;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Component;
/**
* @author zouzhiwen
*/
@Component
public class DomainRouter {
private final AssistantProperties properties;
private final ChatModel chatModel;
public DomainRouter(AssistantProperties properties, ChatModel chatModel) {
this.properties = properties;
this.chatModel = chatModel;
}
public RouteResult route(String question) {
Optional<RouteResult> ruleResult = matchByKeyword(question);
if (ruleResult.isPresent()) {
return ruleResult.get();
}
if (!properties.getRouting().isUseLlm()) {
return new RouteResult(properties.getRouting().getFallbackDomain(), "fallback");
}
String llmDomain = classifyByLlm(question);
if (llmDomain != null && !llmDomain.isEmpty()) {
return new RouteResult(llmDomain, "llm_classification");
}
return new RouteResult(properties.getRouting().getFallbackDomain(), "fallback");
}
private Optional<RouteResult> matchByKeyword(String question) {
String normalized = question == null ? "" : question.toLowerCase(Locale.ROOT);
int bestScore = 0;
String bestDomain = null;
for (AssistantProperties.DomainConfig domain : properties.getDomains()) {
int score = 0;
for (String keyword : domain.getKeywords()) {
if (keyword != null && !keyword.isEmpty() && normalized.contains(keyword.toLowerCase(Locale.ROOT))) {
score++;
}
}
if (score > bestScore) {
bestScore = score;
bestDomain = domain.getId();
}
}
if (bestDomain == null) {
return Optional.empty();
}
return Optional.of(new RouteResult(bestDomain, "keyword_match:" + bestScore));
}
private String classifyByLlm(String question) {
StringBuilder domainList = new StringBuilder();
for (AssistantProperties.DomainConfig domain : properties.getDomains()) {
domainList.append("- ")
.append(domain.getId())
.append(": ")
.append(domain.getName())
.append("\n");
}
String prompt = "Select the best domain for the question. "
+ "Return only the domain id from the list.\n\n"
+ "Domains:\n" + domainList
+ "\nQuestion:\n" + question;
Prompt chatPrompt = new Prompt(List.of(new UserMessage(prompt)));
String content = chatModel.call(chatPrompt).getResult().getOutput().getContent();
if (content == null) {
return null;
}
for (AssistantProperties.DomainConfig domain : properties.getDomains()) {
if (content.contains(domain.getId())) {
return domain.getId();
}
}
return null;
}
public static class RouteResult {
private final String domainId;
private final String reason;
public RouteResult(String domainId, String reason) {
this.domainId = domainId;
this.reason = reason;
}
public String getDomainId() {
return domainId;
}
public String getReason() {
return reason;
}
}
}

View File

@ -0,0 +1,70 @@
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

@ -0,0 +1,142 @@
package cn.kazusa.ai.assistant.service;
import cn.kazusa.ai.assistant.config.AssistantProperties;
import cn.kazusa.ai.assistant.entity.VectorDocument;
import cn.kazusa.ai.assistant.mapper.VectorDocumentMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* @author zouzhiwen
*/
@Component
public class VectorStoreService {
private final VectorDocumentMapper vectorDocumentMapper;
private final JdbcTemplate jdbcTemplate;
private final EmbeddingModel embeddingModel;
private final ObjectMapper objectMapper;
private final AssistantProperties properties;
public VectorStoreService(
VectorDocumentMapper vectorDocumentMapper,
JdbcTemplate jdbcTemplate,
EmbeddingModel embeddingModel,
ObjectMapper objectMapper,
AssistantProperties properties
) {
this.vectorDocumentMapper = vectorDocumentMapper;
this.jdbcTemplate = jdbcTemplate;
this.embeddingModel = embeddingModel;
this.objectMapper = objectMapper;
this.properties = properties;
initSchema();
}
public void upsert(String tenantId, 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 {
doc.setMetadata(objectMapper.writeValueAsString(record.getMetadata()));
} catch (Exception e) {
doc.setMetadata("{}");
}
doc.setEmbedding(record.getEmbedding());
VectorDocument existing = vectorDocumentMapper.selectById(record.getId());
if (existing != null) {
vectorDocumentMapper.updateById(doc);
} else {
vectorDocumentMapper.insert(doc);
}
}
}
public List<DocumentRecord> similaritySearch(String tenantId, 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<DocumentRecord> result = new ArrayList<>();
for (VectorDocument doc : docs) {
Map<String, Object> metadata;
try {
metadata = objectMapper.readValue(doc.getMetadata(), Map.class);
} catch (Exception e) {
metadata = new HashMap<>();
}
result.add(new DocumentRecord(doc.getId(), doc.getContent(), metadata, new float[0]));
}
return result;
}
public float[] embed(String text) {
return embeddingModel.embed(text);
}
private String arrayToString(float[] values) {
StringBuilder builder = new StringBuilder();
builder.append('[');
for (int i = 0; i < values.length; i++) {
if (i > 0) {
builder.append(',');
}
builder.append(values[i]);
}
builder.append(']');
return builder.toString();
}
private void initSchema() {
String table = properties.getVector().getTable();
int dimension = properties.getVector().getDimension();
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)");
}
public static class DocumentRecord {
private final String id;
private final String content;
private final Map<String, Object> metadata;
private final float[] embedding;
public DocumentRecord(String id, String content, Map<String, Object> metadata, float[] embedding) {
this.id = id;
this.content = content;
this.metadata = metadata;
this.embedding = embedding;
}
public String getId() {
return id;
}
public String getContent() {
return content;
}
public Map<String, Object> getMetadata() {
return metadata;
}
public float[] getEmbedding() {
return embedding;
}
}
}

View File

@ -0,0 +1,41 @@
package cn.kazusa.ai.assistant.typehandler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import org.postgresql.util.PGobject;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* @author zouzhiwen
*/
@MappedTypes(String.class)
public class JsonbTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
PGobject pgObject = new PGobject();
pgObject.setType("jsonb");
pgObject.setValue(parameter);
ps.setObject(i, pgObject);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return rs.getString(columnName);
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return rs.getString(columnIndex);
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return cs.getString(columnIndex);
}
}

View File

@ -0,0 +1,73 @@
package cn.kazusa.ai.assistant.typehandler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import org.postgresql.util.PGobject;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* @author zouzhiwen
*/
@MappedTypes(float[].class)
public class VectorFloatTypeHandler extends BaseTypeHandler<float[]> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, float[] parameter, JdbcType jdbcType) throws SQLException {
PGobject pgObject = new PGobject();
pgObject.setType("vector");
pgObject.setValue(arrayToString(parameter));
ps.setObject(i, pgObject);
}
@Override
public float[] getNullableResult(ResultSet rs, String columnName) throws SQLException {
String value = rs.getString(columnName);
return parseVector(value);
}
@Override
public float[] getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String value = rs.getString(columnIndex);
return parseVector(value);
}
@Override
public float[] getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String value = cs.getString(columnIndex);
return parseVector(value);
}
private String arrayToString(float[] values) {
StringBuilder builder = new StringBuilder();
builder.append('[');
for (int i = 0; i < values.length; i++) {
if (i > 0) {
builder.append(',');
}
builder.append(values[i]);
}
builder.append(']');
return builder.toString();
}
private float[] parseVector(String value) {
if (value == null || value.isEmpty()) {
return new float[0];
}
String content = value.substring(1, value.length() - 1);
if (content.isEmpty()) {
return new float[0];
}
String[] parts = content.split(",");
float[] result = new float[parts.length];
for (int i = 0; i < parts.length; i++) {
result[i] = Float.parseFloat(parts[i].trim());
}
return result;
}
}

View File

@ -0,0 +1,28 @@
server:
port: 8010
spring:
config:
import: classpath:domains.yml
datasource:
url: jdbc:postgresql://localhost:5432/assistant
username: postgres
password: postgres
ai:
ollama:
base-url: http://localhost:11434
chat:
options:
model: qwen2.5:7b
embedding:
options:
model: bge-m3
assistant:
default-tenant-id: customer_a
routing:
use-llm: true
fallback-domain: inventory
vector:
table: assistant_vectors
dimension: 1024

View File

@ -0,0 +1,2 @@
{"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

@ -0,0 +1,2 @@
{"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

@ -0,0 +1,2 @@
{"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

@ -0,0 +1,73 @@
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

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.kazusa.ai.assistant.mapper.VectorDocumentMapper">
<select id="similaritySearch" resultType="cn.kazusa.ai.assistant.entity.VectorDocument">
<![CDATA[
SELECT id, tenant_id, domain_id, content, metadata, embedding
FROM assistant_vectors
WHERE tenant_id = #{tenantId}
AND domain_id = #{domainId}
ORDER BY embedding <=> #{embedding}::vector
LIMIT #{topK}
]]>
</select>
</mapper>