init
This commit is contained in:
commit
966c269681
|
|
@ -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": "订单有多少?"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue