From 966c269681e56869161f971fee2c2b91fa1606e3 Mon Sep 17 00:00:00 2001
From: kazusa <409053122@qq.com>
Date: Wed, 7 Jan 2026 18:55:13 +0800
Subject: [PATCH] init
---
README.md | 53 +++
pom.xml | 97 ++++++
.../ai/assistant/AssistantApplication.java | 18 +
.../assistant/config/AssistantProperties.java | 246 +++++++++++++
.../controller/IngestController.java | 33 ++
.../controller/OpenAIController.java | 143 ++++++++
.../ai/assistant/entity/VectorDocument.java | 71 ++++
.../mapper/VectorDocumentMapper.java | 22 ++
.../ai/assistant/model/AssistantAnswer.java | 56 +++
.../model/ChatCompletionRequest.java | 46 +++
.../model/ChatCompletionResponse.java | 136 ++++++++
.../ai/assistant/model/ChatMessage.java | 25 ++
.../assistant/service/AssistantService.java | 325 ++++++++++++++++++
.../ai/assistant/service/DomainFetcher.java | 205 +++++++++++
.../ai/assistant/service/DomainRouter.java | 106 ++++++
.../ai/assistant/service/TimeRangeParser.java | 70 ++++
.../assistant/service/VectorStoreService.java | 142 ++++++++
.../typehandler/JsonbTypeHandler.java | 41 +++
.../typehandler/VectorFloatTypeHandler.java | 73 ++++
src/main/resources/application.yml | 28 ++
.../data/sample_inventory_customer_a.jsonl | 2 +
.../data/sample_orders_customer_a.jsonl | 2 +
.../data/sample_returns_customer_a.jsonl | 2 +
src/main/resources/domains.yml | 73 ++++
.../resources/mapper/VectorDocumentMapper.xml | 16 +
25 files changed, 2031 insertions(+)
create mode 100644 README.md
create mode 100644 pom.xml
create mode 100644 src/main/java/cn/kazusa/ai/assistant/AssistantApplication.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/config/AssistantProperties.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/controller/IngestController.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/controller/OpenAIController.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/entity/VectorDocument.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/mapper/VectorDocumentMapper.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/model/AssistantAnswer.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/model/ChatCompletionRequest.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/model/ChatCompletionResponse.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/model/ChatMessage.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/service/AssistantService.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/service/DomainFetcher.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/service/DomainRouter.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/service/TimeRangeParser.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/service/VectorStoreService.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/typehandler/JsonbTypeHandler.java
create mode 100644 src/main/java/cn/kazusa/ai/assistant/typehandler/VectorFloatTypeHandler.java
create mode 100644 src/main/resources/application.yml
create mode 100644 src/main/resources/data/sample_inventory_customer_a.jsonl
create mode 100644 src/main/resources/data/sample_orders_customer_a.jsonl
create mode 100644 src/main/resources/data/sample_returns_customer_a.jsonl
create mode 100644 src/main/resources/domains.yml
create mode 100644 src/main/resources/mapper/VectorDocumentMapper.xml
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3054961
--- /dev/null
+++ b/README.md
@@ -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": "订单有多少?"}
+ ]
+}
+```
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..db96263
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,97 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+
+ cn.kazusa.ai
+ assistant
+ 0.1.0-SNAPSHOT
+ ai-assistant-java
+ Independent AI assistant service
+
+
+ 17
+ 1.0.0-M5
+
+
+
+
+
+ org.springframework.ai
+ spring-ai-bom
+ ${spring-ai.version}
+ pom
+ import
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+ 3.5.5
+
+
+ org.springframework.ai
+ spring-ai-ollama-spring-boot-starter
+
+
+ io.github.openfeign
+ feign-core
+ 13.2
+
+
+ io.github.openfeign
+ feign-okhttp
+ 13.2
+
+
+ org.postgresql
+ postgresql
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+ spring-milestones
+ Spring Milestones
+ https://repo.spring.io/milestone
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/src/main/java/cn/kazusa/ai/assistant/AssistantApplication.java b/src/main/java/cn/kazusa/ai/assistant/AssistantApplication.java
new file mode 100644
index 0000000..0453801
--- /dev/null
+++ b/src/main/java/cn/kazusa/ai/assistant/AssistantApplication.java
@@ -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);
+ }
+}
diff --git a/src/main/java/cn/kazusa/ai/assistant/config/AssistantProperties.java b/src/main/java/cn/kazusa/ai/assistant/config/AssistantProperties.java
new file mode 100644
index 0000000..ca6ce7c
--- /dev/null
+++ b/src/main/java/cn/kazusa/ai/assistant/config/AssistantProperties.java
@@ -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 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 getDomains() {
+ return domains;
+ }
+
+ public void setDomains(List 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 keywords = new ArrayList<>();
+ private List fields = new ArrayList<>();
+ private Map statusMappings = new HashMap<>();
+ private QuerySchema querySchema = new QuerySchema();
+ private List sources = new ArrayList<>();
+
+ public String getId() {
+ return id;
+ }
+
+ 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 getKeywords() {
+ return keywords;
+ }
+
+ public void setKeywords(List keywords) {
+ this.keywords = keywords;
+ }
+
+ public List getFields() {
+ return fields;
+ }
+
+ public void setFields(List fields) {
+ this.fields = fields;
+ }
+
+ public Map getStatusMappings() {
+ return statusMappings;
+ }
+
+ public void setStatusMappings(Map statusMappings) {
+ this.statusMappings = statusMappings;
+ }
+
+ public QuerySchema getQuerySchema() {
+ return querySchema;
+ }
+
+ public void setQuerySchema(QuerySchema querySchema) {
+ this.querySchema = querySchema;
+ }
+
+ public List getSources() {
+ return sources;
+ }
+
+ public void setSources(List sources) {
+ this.sources = sources;
+ }
+ }
+
+ public static class QuerySchema {
+ private List allowedParams = new ArrayList<>();
+ private Map> enums = new HashMap<>();
+
+ public List getAllowedParams() {
+ return allowedParams;
+ }
+
+ public void setAllowedParams(List allowedParams) {
+ this.allowedParams = allowedParams;
+ }
+
+ public Map> getEnums() {
+ return enums;
+ }
+
+ public void setEnums(Map> enums) {
+ this.enums = enums;
+ }
+ }
+
+ public static class DomainSource {
+ private String type;
+ private String url;
+ private String method = "POST";
+ private String path;
+ private String itemsPath = "";
+ private Map headers = new HashMap<>();
+ private Map body = new HashMap<>();
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public String getMethod() {
+ return method;
+ }
+
+ public void setMethod(String method) {
+ this.method = method;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ public String getItemsPath() {
+ return itemsPath;
+ }
+
+ public void setItemsPath(String itemsPath) {
+ this.itemsPath = itemsPath;
+ }
+
+ public Map getHeaders() {
+ return headers;
+ }
+
+ public void setHeaders(Map headers) {
+ this.headers = headers;
+ }
+
+ public Map getBody() {
+ return body;
+ }
+
+ public void setBody(Map body) {
+ this.body = body;
+ }
+ }
+}
diff --git a/src/main/java/cn/kazusa/ai/assistant/controller/IngestController.java b/src/main/java/cn/kazusa/ai/assistant/controller/IngestController.java
new file mode 100644
index 0000000..448f593
--- /dev/null
+++ b/src/main/java/cn/kazusa/ai/assistant/controller/IngestController.java
@@ -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 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));
+ }
+}
diff --git a/src/main/java/cn/kazusa/ai/assistant/controller/OpenAIController.java b/src/main/java/cn/kazusa/ai/assistant/controller/OpenAIController.java
new file mode 100644
index 0000000..c195a73
--- /dev/null
+++ b/src/main/java/cn/kazusa/ai/assistant/controller/OpenAIController.java
@@ -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 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 buildChunk(String role, String content, String model, boolean ongoing) {
+ Map 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 delta = new HashMap<>();
+ if (role != null) {
+ delta.put("role", role);
+ }
+ if (content != null) {
+ delta.put("content", content);
+ }
+ Map 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 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;
+ }
+}
diff --git a/src/main/java/cn/kazusa/ai/assistant/entity/VectorDocument.java b/src/main/java/cn/kazusa/ai/assistant/entity/VectorDocument.java
new file mode 100644
index 0000000..83f6689
--- /dev/null
+++ b/src/main/java/cn/kazusa/ai/assistant/entity/VectorDocument.java
@@ -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;
+ }
+}
diff --git a/src/main/java/cn/kazusa/ai/assistant/mapper/VectorDocumentMapper.java b/src/main/java/cn/kazusa/ai/assistant/mapper/VectorDocumentMapper.java
new file mode 100644
index 0000000..574b81c
--- /dev/null
+++ b/src/main/java/cn/kazusa/ai/assistant/mapper/VectorDocumentMapper.java
@@ -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 {
+
+ List similaritySearch(
+ @Param("tenantId") String tenantId,
+ @Param("domainId") String domainId,
+ @Param("embedding") String embedding,
+ @Param("topK") int topK
+ );
+}
diff --git a/src/main/java/cn/kazusa/ai/assistant/model/AssistantAnswer.java b/src/main/java/cn/kazusa/ai/assistant/model/AssistantAnswer.java
new file mode 100644
index 0000000..90a4058
--- /dev/null
+++ b/src/main/java/cn/kazusa/ai/assistant/model/AssistantAnswer.java
@@ -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