feat(ai): 新增AI模块功能
- 添加Ollama配置参数,包括基础URL、模型设置、超时配置等 - 创建AI知识库相关数据库表(文档表和分段表) - 实现AI数据分析服务,支持订单数据查询和分析 - 开发AI聊天控制器,提供模型列表、对话和流式对话功能 - 构建知识库RAG服务,支持文档上传、CMS同步和问答功能 - 添加多种AI相关的DTO类和实体类 - 实现AI嵌入向量计算和相似度匹配算法 - 集成Tika用于文档内容提取和解析
This commit is contained in:
61
docs/ai/README.md
Normal file
61
docs/ai/README.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# AI 模块(Ollama + RAG + 订单分析)
|
||||||
|
|
||||||
|
## 1. 配置
|
||||||
|
|
||||||
|
见 `src/main/resources/application.yml`:
|
||||||
|
|
||||||
|
- `ai.ollama.base-url`:主地址(例如 `https://ai-api.websoft.top`)
|
||||||
|
- `ai.ollama.fallback-url`:备用地址(例如 `http://47.119.165.234:11434`)
|
||||||
|
- `ai.ollama.chat-model`:对话模型(`qwen3.5:cloud`)
|
||||||
|
- `ai.ollama.embed-model`:向量模型(`qwen3-embedding:4b`)
|
||||||
|
|
||||||
|
## 2. 建表(知识库)
|
||||||
|
|
||||||
|
执行:`docs/ai/ai_kb_tables.sql`
|
||||||
|
|
||||||
|
## 3. API
|
||||||
|
|
||||||
|
说明:所有接口默认需要登录(`@PreAuthorize("isAuthenticated()")`),并且要求能够拿到 `tenantId`(header 或登录用户)。
|
||||||
|
|
||||||
|
### 3.1 对话
|
||||||
|
|
||||||
|
- `GET /api/ai/models`:获取 Ollama 模型列表
|
||||||
|
- `POST /api/ai/chat`:非流式对话
|
||||||
|
- `POST /api/ai/chat/stream`:流式对话(SSE)
|
||||||
|
- `GET /api/ai/chat/stream?prompt=...`:流式对话(SSE,适配 EventSource)
|
||||||
|
|
||||||
|
请求示例(非流式):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "帮我写一个退款流程说明"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 知识库(RAG)
|
||||||
|
|
||||||
|
- `POST /api/ai/kb/upload`:上传文档入库(建议 txt/md/html)
|
||||||
|
- `POST /api/ai/kb/sync/cms`:同步 CMS 已发布文章到知识库(当前租户)
|
||||||
|
- `POST /api/ai/kb/query`:仅检索 topK
|
||||||
|
- `POST /api/ai/kb/ask`:检索 + 生成答案(答案要求引用 chunk_id)
|
||||||
|
|
||||||
|
请求示例(ask):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question": "怎么开具发票?",
|
||||||
|
"topK": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 商城订单分析(按租户/按天)
|
||||||
|
|
||||||
|
- `POST /api/ai/analytics/query`:返回按天指标数据
|
||||||
|
- `POST /api/ai/analytics/ask`:基于指标数据生成分析结论
|
||||||
|
|
||||||
|
请求示例(ask):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question": "最近30天支付率有没有明显下滑?请给出原因排查建议。",
|
||||||
|
"startDate": "2026-02-01",
|
||||||
|
"endDate": "2026-02-27"
|
||||||
|
}
|
||||||
|
```
|
||||||
39
docs/ai/ai_kb_tables.sql
Normal file
39
docs/ai/ai_kb_tables.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- AI 知识库(RAG)建表脚本(MySQL)
|
||||||
|
-- 说明:本项目使用 MyBatis-Plus 默认命名规则(AiKbDocument -> ai_kb_document)。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `ai_kb_document` (
|
||||||
|
`document_id` INT NOT NULL AUTO_INCREMENT COMMENT '文档ID',
|
||||||
|
`title` VARCHAR(255) NULL COMMENT '标题',
|
||||||
|
`source_type` VARCHAR(32) NULL COMMENT '来源类型:upload/cms',
|
||||||
|
`source_id` INT NULL COMMENT '来源ID(如 cms.article_id)',
|
||||||
|
`source_ref` VARCHAR(255) NULL COMMENT '来源引用(如文件名、文章 code)',
|
||||||
|
`content_hash` CHAR(64) NULL COMMENT '内容hash(SHA-256),用于增量同步',
|
||||||
|
`status` TINYINT NULL DEFAULT 0 COMMENT '状态',
|
||||||
|
`deleted` TINYINT NULL DEFAULT 0 COMMENT '逻辑删除:0否1是',
|
||||||
|
`tenant_id` INT NOT NULL COMMENT '租户ID',
|
||||||
|
`update_time` DATETIME NULL COMMENT '更新时间',
|
||||||
|
`create_time` DATETIME NULL COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`document_id`),
|
||||||
|
KEY `idx_ai_kb_document_tenant` (`tenant_id`),
|
||||||
|
KEY `idx_ai_kb_document_source` (`tenant_id`, `source_type`, `source_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 知识库文档';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `ai_kb_chunk` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '自增ID',
|
||||||
|
`document_id` INT NOT NULL COMMENT '文档ID',
|
||||||
|
`chunk_id` VARCHAR(64) NOT NULL COMMENT 'chunk 唯一ID(用于引用)',
|
||||||
|
`chunk_index` INT NULL COMMENT 'chunk 序号',
|
||||||
|
`title` VARCHAR(255) NULL COMMENT '标题(冗余,便于展示)',
|
||||||
|
`content` LONGTEXT NULL COMMENT 'chunk 文本',
|
||||||
|
`content_hash` CHAR(64) NULL COMMENT 'chunk 内容hash',
|
||||||
|
`embedding` LONGTEXT NULL COMMENT 'embedding(JSON数组)',
|
||||||
|
`embedding_norm` DOUBLE NULL COMMENT 'embedding L2 范数',
|
||||||
|
`deleted` TINYINT NULL DEFAULT 0 COMMENT '逻辑删除:0否1是',
|
||||||
|
`tenant_id` INT NOT NULL COMMENT '租户ID',
|
||||||
|
`create_time` DATETIME NULL COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_ai_kb_chunk_chunk_id` (`chunk_id`),
|
||||||
|
KEY `idx_ai_kb_chunk_tenant` (`tenant_id`),
|
||||||
|
KEY `idx_ai_kb_chunk_document` (`document_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 知识库 chunk';
|
||||||
|
|
||||||
194
src/main/java/com/gxwebsoft/ai/client/OllamaClient.java
Normal file
194
src/main/java/com/gxwebsoft/ai/client/OllamaClient.java
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package com.gxwebsoft.ai.client;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.gxwebsoft.ai.client.dto.*;
|
||||||
|
import com.gxwebsoft.ai.config.AiOllamaProperties;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.core.utils.JSONUtil;
|
||||||
|
import okhttp3.*;
|
||||||
|
import okio.BufferedSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轻量 Ollama HTTP Client(兼容 /api/chat、/api/embeddings、/api/tags)。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OllamaClient {
|
||||||
|
@Resource
|
||||||
|
private AiOllamaProperties props;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private volatile OkHttpClient http;
|
||||||
|
|
||||||
|
private OkHttpClient http() {
|
||||||
|
OkHttpClient c = http;
|
||||||
|
if (c != null) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (http == null) {
|
||||||
|
http = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(Duration.ofMillis(props.getConnectTimeoutMs()))
|
||||||
|
.readTimeout(Duration.ofMillis(props.getReadTimeoutMs()))
|
||||||
|
.writeTimeout(Duration.ofMillis(props.getWriteTimeoutMs()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public OllamaTagsResponse tags() {
|
||||||
|
return getJson("/api/tags", OllamaTagsResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OllamaChatResponse chat(OllamaChatRequest req) {
|
||||||
|
if (req.getStream() == null) {
|
||||||
|
req.setStream(false);
|
||||||
|
}
|
||||||
|
return postJson("/api/chat", req, OllamaChatResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式对话:Ollama 会返回按行分隔的 JSON。
|
||||||
|
*/
|
||||||
|
public void chatStream(OllamaChatRequest req, Consumer<OllamaChatResponse> onEvent) {
|
||||||
|
Objects.requireNonNull(onEvent, "onEvent");
|
||||||
|
if (req.getStream() == null) {
|
||||||
|
req.setStream(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Request request = buildPost(baseUrl(), "/api/chat", JSONUtil.toJSONString(req));
|
||||||
|
try (Response resp = http().newCall(request).execute()) {
|
||||||
|
if (!resp.isSuccessful()) {
|
||||||
|
throw new BusinessException("Ollama chat stream failed: HTTP " + resp.code());
|
||||||
|
}
|
||||||
|
ResponseBody body = resp.body();
|
||||||
|
if (body == null) {
|
||||||
|
throw new BusinessException("Ollama chat stream failed: empty body");
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedSource source = body.source();
|
||||||
|
String line;
|
||||||
|
while ((line = source.readUtf8Line()) != null) {
|
||||||
|
line = line.trim();
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
OllamaChatResponse event = objectMapper.readValue(line, OllamaChatResponse.class);
|
||||||
|
onEvent.accept(event);
|
||||||
|
if (Boolean.TRUE.equals(event.getDone())) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new BusinessException("Ollama chat stream IO error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public OllamaEmbeddingResponse embedding(String prompt) {
|
||||||
|
OllamaEmbeddingRequest req = new OllamaEmbeddingRequest();
|
||||||
|
req.setModel(props.getEmbedModel());
|
||||||
|
req.setPrompt(prompt);
|
||||||
|
return postJson("/api/embeddings", req, OllamaEmbeddingResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String baseUrl() {
|
||||||
|
if (props.getBaseUrl() == null || props.getBaseUrl().trim().isEmpty()) {
|
||||||
|
throw new BusinessException("ai.ollama.base-url 未配置");
|
||||||
|
}
|
||||||
|
return props.getBaseUrl().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fallbackUrl() {
|
||||||
|
if (props.getFallbackUrl() == null || props.getFallbackUrl().trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return props.getFallbackUrl().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T getJson(String path, Class<T> clazz) {
|
||||||
|
try {
|
||||||
|
return getJsonOnce(baseUrl(), path, clazz);
|
||||||
|
} catch (Exception e) {
|
||||||
|
String fb = fallbackUrl();
|
||||||
|
if (fb == null) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return getJsonOnce(fb, path, clazz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T getJsonOnce(String base, String path, Class<T> clazz) {
|
||||||
|
Request req = new Request.Builder()
|
||||||
|
.url(join(base, path))
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response resp = http().newCall(req).execute()) {
|
||||||
|
if (!resp.isSuccessful()) {
|
||||||
|
throw new BusinessException("Ollama GET failed: HTTP " + resp.code());
|
||||||
|
}
|
||||||
|
ResponseBody body = resp.body();
|
||||||
|
if (body == null) {
|
||||||
|
throw new BusinessException("Ollama GET failed: empty body");
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(body.string(), clazz);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new BusinessException("Ollama GET IO error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T postJson(String path, Object payload, Class<T> clazz) {
|
||||||
|
String json = JSONUtil.toJSONString(payload);
|
||||||
|
try {
|
||||||
|
return postJsonOnce(baseUrl(), path, json, clazz);
|
||||||
|
} catch (Exception e) {
|
||||||
|
String fb = fallbackUrl();
|
||||||
|
if (fb == null) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return postJsonOnce(fb, path, json, clazz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T postJsonOnce(String base, String path, String json, Class<T> clazz) {
|
||||||
|
Request req = buildPost(base, path, json);
|
||||||
|
try (Response resp = http().newCall(req).execute()) {
|
||||||
|
if (!resp.isSuccessful()) {
|
||||||
|
throw new BusinessException("Ollama POST failed: HTTP " + resp.code());
|
||||||
|
}
|
||||||
|
ResponseBody body = resp.body();
|
||||||
|
if (body == null) {
|
||||||
|
throw new BusinessException("Ollama POST failed: empty body");
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(body.string(), clazz);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new BusinessException("Ollama POST IO error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Request buildPost(String base, String path, String json) {
|
||||||
|
RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"));
|
||||||
|
return new Request.Builder()
|
||||||
|
.url(join(base, path))
|
||||||
|
.post(body)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String join(String base, String path) {
|
||||||
|
String b = base;
|
||||||
|
if (b.endsWith("/")) {
|
||||||
|
b = b.substring(0, b.length() - 1);
|
||||||
|
}
|
||||||
|
String p = path.startsWith("/") ? path : ("/" + path);
|
||||||
|
return b + p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.gxwebsoft.ai.client.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class OllamaChatRequest {
|
||||||
|
private String model;
|
||||||
|
private List<OllamaMessage> messages;
|
||||||
|
private Boolean stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ollama options,例如:temperature、top_k、top_p、num_predict...
|
||||||
|
*/
|
||||||
|
private Map<String, Object> options;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.gxwebsoft.ai.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class OllamaChatResponse {
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@JsonProperty("created_at")
|
||||||
|
private String createdAt;
|
||||||
|
|
||||||
|
private OllamaMessage message;
|
||||||
|
|
||||||
|
private Boolean done;
|
||||||
|
|
||||||
|
@JsonProperty("total_duration")
|
||||||
|
private Long totalDuration;
|
||||||
|
|
||||||
|
@JsonProperty("prompt_eval_count")
|
||||||
|
private Integer promptEvalCount;
|
||||||
|
|
||||||
|
@JsonProperty("eval_count")
|
||||||
|
private Integer evalCount;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.gxwebsoft.ai.client.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class OllamaEmbeddingRequest {
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ollama embeddings 目前常用字段为 prompt。
|
||||||
|
*/
|
||||||
|
private String prompt;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.gxwebsoft.ai.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class OllamaEmbeddingResponse {
|
||||||
|
private List<Double> embedding;
|
||||||
|
}
|
||||||
|
|
||||||
14
src/main/java/com/gxwebsoft/ai/client/dto/OllamaMessage.java
Normal file
14
src/main/java/com/gxwebsoft/ai/client/dto/OllamaMessage.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.gxwebsoft.ai.client.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class OllamaMessage {
|
||||||
|
private String role;
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.gxwebsoft.ai.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class OllamaTagsResponse {
|
||||||
|
private List<Model> models;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public static class Model {
|
||||||
|
private String name;
|
||||||
|
private Long size;
|
||||||
|
private String digest;
|
||||||
|
private String modified_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.gxwebsoft.ai.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ollama API 配置。
|
||||||
|
*
|
||||||
|
* 说明:本项目通过自建 Ollama 网关提供服务,因此这里用 baseUrl + fallbackUrl。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "ai.ollama")
|
||||||
|
public class AiOllamaProperties {
|
||||||
|
/**
|
||||||
|
* 主地址,例如:https://ai-api.websoft.top
|
||||||
|
*/
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备用地址,例如:http://47.119.165.234:11434
|
||||||
|
*/
|
||||||
|
private String fallbackUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对话模型,例如:qwen3.5:cloud
|
||||||
|
*/
|
||||||
|
private String chatModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向量模型,例如:qwen3-embedding:4b
|
||||||
|
*/
|
||||||
|
private String embedModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 超时(毫秒)。
|
||||||
|
*/
|
||||||
|
private long connectTimeoutMs = 10_000;
|
||||||
|
private long readTimeoutMs = 300_000;
|
||||||
|
private long writeTimeoutMs = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 并发上限(用于 embedding/入库等批处理场景)。
|
||||||
|
*/
|
||||||
|
private int maxConcurrency = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG:检索候选 chunk 最大数量(避免一次性拉取过多数据导致内存/耗时过高)。
|
||||||
|
*/
|
||||||
|
private int ragMaxCandidates = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG:检索返回 topK。
|
||||||
|
*/
|
||||||
|
private int ragTopK = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG:单个 chunk 最大字符数(用于入库切分)。
|
||||||
|
*/
|
||||||
|
private int ragChunkSize = 800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG:chunk 重叠字符数(用于减少语义断裂)。
|
||||||
|
*/
|
||||||
|
private int ragChunkOverlap = 120;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.gxwebsoft.ai.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.ai.dto.*;
|
||||||
|
import com.gxwebsoft.ai.service.AiAnalyticsService;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
@Tag(name = "AI - 订单数据分析")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/ai/analytics")
|
||||||
|
public class AiAnalyticsController extends BaseController {
|
||||||
|
@Resource
|
||||||
|
private AiAnalyticsService analyticsService;
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "查询商城订单按天指标(当前租户)")
|
||||||
|
@PostMapping("/query")
|
||||||
|
public ApiResult<AiShopMetricsQueryResult> query(@RequestBody AiShopMetricsQueryRequest request) {
|
||||||
|
Integer tenantId = getTenantId();
|
||||||
|
return success(analyticsService.queryShopMetrics(tenantId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "AI 解析并输出订单分析结论(当前租户)")
|
||||||
|
@PostMapping("/ask")
|
||||||
|
public ApiResult<AiAnalyticsAskResult> ask(@RequestBody AiAnalyticsAskRequest request) {
|
||||||
|
Integer tenantId = getTenantId();
|
||||||
|
return success(analyticsService.askShopAnalytics(tenantId, request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.gxwebsoft.ai.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.ai.client.OllamaClient;
|
||||||
|
import com.gxwebsoft.ai.client.dto.OllamaChatResponse;
|
||||||
|
import com.gxwebsoft.ai.client.dto.OllamaTagsResponse;
|
||||||
|
import com.gxwebsoft.ai.dto.AiChatRequest;
|
||||||
|
import com.gxwebsoft.ai.dto.AiChatResult;
|
||||||
|
import com.gxwebsoft.ai.dto.AiMessage;
|
||||||
|
import com.gxwebsoft.ai.service.AiChatService;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
@Tag(name = "AI - 对话")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/ai")
|
||||||
|
public class AiChatController extends BaseController {
|
||||||
|
@Resource
|
||||||
|
private OllamaClient ollamaClient;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private AiChatService aiChatService;
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "获取 Ollama 模型列表")
|
||||||
|
@GetMapping("/models")
|
||||||
|
public ApiResult<OllamaTagsResponse> models() {
|
||||||
|
return success(ollamaClient.tags());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "非流式对话")
|
||||||
|
@PostMapping("/chat")
|
||||||
|
public ApiResult<AiChatResult> chat(@RequestBody AiChatRequest request) {
|
||||||
|
return success(aiChatService.chat(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "流式对话(SSE)")
|
||||||
|
@PostMapping("/chat/stream")
|
||||||
|
public SseEmitter chatStream(@RequestBody AiChatRequest request) {
|
||||||
|
// 10 分钟超时(可根据前端需要调整)
|
||||||
|
SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);
|
||||||
|
|
||||||
|
CompletableFuture.runAsync(() -> {
|
||||||
|
try {
|
||||||
|
aiChatService.chatStream(request,
|
||||||
|
delta -> {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("delta").data(delta));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 客户端断开会触发 send 异常,这里直接结束即可
|
||||||
|
emitter.complete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(OllamaChatResponse done) -> {
|
||||||
|
try {
|
||||||
|
Map<String, Object> meta = new LinkedHashMap<>();
|
||||||
|
meta.put("model", done.getModel());
|
||||||
|
meta.put("prompt_eval_count", done.getPromptEvalCount());
|
||||||
|
meta.put("eval_count", done.getEvalCount());
|
||||||
|
meta.put("total_duration", done.getTotalDuration());
|
||||||
|
emitter.send(SseEmitter.event().name("done").data(meta));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
} finally {
|
||||||
|
emitter.complete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "流式对话(SSE, GET 版本,便于 EventSource)")
|
||||||
|
@GetMapping("/chat/stream")
|
||||||
|
public SseEmitter chatStreamGet(@RequestParam("prompt") String prompt) {
|
||||||
|
AiChatRequest req = new AiChatRequest();
|
||||||
|
req.setMessages(Collections.singletonList(new AiMessage("user", prompt)));
|
||||||
|
return chatStream(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.gxwebsoft.ai.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.ai.dto.*;
|
||||||
|
import com.gxwebsoft.ai.service.AiKbRagService;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
@Tag(name = "AI - 知识库(RAG)")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/ai/kb")
|
||||||
|
public class AiKbController extends BaseController {
|
||||||
|
@Resource
|
||||||
|
private AiKbRagService ragService;
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "上传文档入库(txt/md/html 优先)")
|
||||||
|
@PostMapping("/upload")
|
||||||
|
public ApiResult<AiKbIngestResult> upload(@RequestParam("file") MultipartFile file) {
|
||||||
|
Integer tenantId = getTenantId();
|
||||||
|
return success(ragService.ingestUpload(tenantId, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "同步 CMS 文章到知识库(当前租户)")
|
||||||
|
@PostMapping("/sync/cms")
|
||||||
|
public ApiResult<AiKbIngestResult> syncCms() {
|
||||||
|
Integer tenantId = getTenantId();
|
||||||
|
return success(ragService.syncCms(tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "仅检索(返回 topK 命中)")
|
||||||
|
@PostMapping("/query")
|
||||||
|
public ApiResult<AiKbQueryResult> query(@RequestBody AiKbQueryRequest request) {
|
||||||
|
Integer tenantId = getTenantId();
|
||||||
|
return success(ragService.query(tenantId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "知识库问答(RAG 生成 + 返回检索结果)")
|
||||||
|
@PostMapping("/ask")
|
||||||
|
public ApiResult<AiKbAskResult> ask(@RequestBody AiKbAskRequest request) {
|
||||||
|
Integer tenantId = getTenantId();
|
||||||
|
return success(ragService.ask(tenantId, request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiAnalyticsAskRequest", description = "AI 数据分析提问")
|
||||||
|
public class AiAnalyticsAskRequest {
|
||||||
|
private String question;
|
||||||
|
private String startDate;
|
||||||
|
private String endDate;
|
||||||
|
}
|
||||||
|
|
||||||
12
src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskResult.java
Normal file
12
src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskResult.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiAnalyticsAskResult", description = "AI 数据分析结果")
|
||||||
|
public class AiAnalyticsAskResult {
|
||||||
|
private String analysis;
|
||||||
|
private AiShopMetricsQueryResult data;
|
||||||
|
}
|
||||||
|
|
||||||
35
src/main/java/com/gxwebsoft/ai/dto/AiChatRequest.java
Normal file
35
src/main/java/com/gxwebsoft/ai/dto/AiChatRequest.java
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiChatRequest", description = "AI 对话请求")
|
||||||
|
public class AiChatRequest {
|
||||||
|
@Schema(description = "可选:直接传一句话。若 messages 为空则使用该字段构造 user message")
|
||||||
|
private String prompt;
|
||||||
|
|
||||||
|
@Schema(description = "可选:OpenAI 风格 messages(role: system/user/assistant)")
|
||||||
|
private List<AiMessage> messages;
|
||||||
|
|
||||||
|
@Schema(description = "可选:覆盖默认模型")
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@Schema(description = "是否流式输出(/chat/stream 端点通常忽略此字段)")
|
||||||
|
private Boolean stream;
|
||||||
|
|
||||||
|
@Schema(description = "temperature")
|
||||||
|
private Double temperature;
|
||||||
|
|
||||||
|
@Schema(description = "top_k")
|
||||||
|
private Integer topK;
|
||||||
|
|
||||||
|
@Schema(description = "top_p")
|
||||||
|
private Double topP;
|
||||||
|
|
||||||
|
@Schema(description = "num_predict(类似 max_tokens)")
|
||||||
|
private Integer numPredict;
|
||||||
|
}
|
||||||
|
|
||||||
15
src/main/java/com/gxwebsoft/ai/dto/AiChatResult.java
Normal file
15
src/main/java/com/gxwebsoft/ai/dto/AiChatResult.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(name = "AiChatResult", description = "AI 对话结果")
|
||||||
|
public class AiChatResult {
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
|
|
||||||
12
src/main/java/com/gxwebsoft/ai/dto/AiKbAskRequest.java
Normal file
12
src/main/java/com/gxwebsoft/ai/dto/AiKbAskRequest.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiKbAskRequest", description = "知识库问答请求")
|
||||||
|
public class AiKbAskRequest {
|
||||||
|
private String question;
|
||||||
|
private Integer topK;
|
||||||
|
}
|
||||||
|
|
||||||
12
src/main/java/com/gxwebsoft/ai/dto/AiKbAskResult.java
Normal file
12
src/main/java/com/gxwebsoft/ai/dto/AiKbAskResult.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiKbAskResult", description = "知识库问答结果")
|
||||||
|
public class AiKbAskResult {
|
||||||
|
private String answer;
|
||||||
|
private AiKbQueryResult retrieval;
|
||||||
|
}
|
||||||
|
|
||||||
15
src/main/java/com/gxwebsoft/ai/dto/AiKbHit.java
Normal file
15
src/main/java/com/gxwebsoft/ai/dto/AiKbHit.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiKbHit", description = "知识库命中")
|
||||||
|
public class AiKbHit {
|
||||||
|
private String chunkId;
|
||||||
|
private Integer documentId;
|
||||||
|
private String title;
|
||||||
|
private Double score;
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
|
|
||||||
15
src/main/java/com/gxwebsoft/ai/dto/AiKbIngestResult.java
Normal file
15
src/main/java/com/gxwebsoft/ai/dto/AiKbIngestResult.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiKbIngestResult", description = "知识库入库结果")
|
||||||
|
public class AiKbIngestResult {
|
||||||
|
private Integer documentId;
|
||||||
|
private String title;
|
||||||
|
private Integer chunks;
|
||||||
|
private Integer updatedDocuments;
|
||||||
|
private Integer skippedDocuments;
|
||||||
|
}
|
||||||
|
|
||||||
12
src/main/java/com/gxwebsoft/ai/dto/AiKbQueryRequest.java
Normal file
12
src/main/java/com/gxwebsoft/ai/dto/AiKbQueryRequest.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiKbQueryRequest", description = "知识库检索请求")
|
||||||
|
public class AiKbQueryRequest {
|
||||||
|
private String query;
|
||||||
|
private Integer topK;
|
||||||
|
}
|
||||||
|
|
||||||
13
src/main/java/com/gxwebsoft/ai/dto/AiKbQueryResult.java
Normal file
13
src/main/java/com/gxwebsoft/ai/dto/AiKbQueryResult.java
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiKbQueryResult", description = "知识库检索结果")
|
||||||
|
public class AiKbQueryResult {
|
||||||
|
private List<AiKbHit> hits;
|
||||||
|
}
|
||||||
|
|
||||||
14
src/main/java/com/gxwebsoft/ai/dto/AiMessage.java
Normal file
14
src/main/java/com/gxwebsoft/ai/dto/AiMessage.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AiMessage {
|
||||||
|
private String role;
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiShopMetricsQueryRequest", description = "商城订单指标查询请求")
|
||||||
|
public class AiShopMetricsQueryRequest {
|
||||||
|
@Schema(description = "开始日期(YYYY-MM-DD)")
|
||||||
|
private String startDate;
|
||||||
|
|
||||||
|
@Schema(description = "结束日期(YYYY-MM-DD),包含该天")
|
||||||
|
private String endDate;
|
||||||
|
|
||||||
|
@Schema(description = "是否按天分组,默认 true")
|
||||||
|
private Boolean groupByDay;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiShopMetricsQueryResult", description = "商城订单指标查询结果")
|
||||||
|
public class AiShopMetricsQueryResult {
|
||||||
|
private Integer tenantId;
|
||||||
|
private String startDate;
|
||||||
|
private String endDate;
|
||||||
|
private List<AiShopMetricsRow> rows;
|
||||||
|
}
|
||||||
|
|
||||||
23
src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsRow.java
Normal file
23
src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsRow.java
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package com.gxwebsoft.ai.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiShopMetricsRow", description = "商城订单指标行(按 tenant/day)")
|
||||||
|
public class AiShopMetricsRow {
|
||||||
|
private Integer tenantId;
|
||||||
|
private String day;
|
||||||
|
|
||||||
|
private Long orderCnt;
|
||||||
|
private Long paidOrderCnt;
|
||||||
|
private BigDecimal gmv;
|
||||||
|
private BigDecimal refundAmt;
|
||||||
|
private Long payUserCnt;
|
||||||
|
|
||||||
|
private BigDecimal aov;
|
||||||
|
private BigDecimal payRate;
|
||||||
|
}
|
||||||
|
|
||||||
57
src/main/java/com/gxwebsoft/ai/entity/AiKbChunk.java
Normal file
57
src/main/java/com/gxwebsoft/ai/entity/AiKbChunk.java
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package com.gxwebsoft.ai.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分段(chunk)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiKbChunk", description = "AI 知识库分段")
|
||||||
|
public class AiKbChunk implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private Integer documentId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外部引用用的唯一 ID(便于在答案里引用)。
|
||||||
|
*/
|
||||||
|
private String chunkId;
|
||||||
|
|
||||||
|
private Integer chunkIndex;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
private String contentHash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* embedding JSON(数组),存成文本便于快速落库。
|
||||||
|
*/
|
||||||
|
private String embedding;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* embedding 的 L2 范数,用于余弦相似度。
|
||||||
|
*/
|
||||||
|
private Double embeddingNorm;
|
||||||
|
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
|
|
||||||
59
src/main/java/com/gxwebsoft/ai/entity/AiKbDocument.java
Normal file
59
src/main/java/com/gxwebsoft/ai/entity/AiKbDocument.java
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package com.gxwebsoft.ai.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库文档(来源:上传、CMS 等)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "AiKbDocument", description = "AI 知识库文档")
|
||||||
|
public class AiKbDocument implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(value = "document_id", type = IdType.AUTO)
|
||||||
|
private Integer documentId;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* upload / cms
|
||||||
|
*/
|
||||||
|
private String sourceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 例如 CMS article_id
|
||||||
|
*/
|
||||||
|
private Integer sourceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 例如文件名、路径等
|
||||||
|
*/
|
||||||
|
private String sourceRef;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档文本内容哈希(用于增量同步/去重)。
|
||||||
|
*/
|
||||||
|
private String contentHash;
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
|
|
||||||
10
src/main/java/com/gxwebsoft/ai/mapper/AiKbChunkMapper.java
Normal file
10
src/main/java/com/gxwebsoft/ai/mapper/AiKbChunkMapper.java
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package com.gxwebsoft.ai.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.gxwebsoft.ai.entity.AiKbChunk;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AiKbChunkMapper extends BaseMapper<AiKbChunk> {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.gxwebsoft.ai.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.gxwebsoft.ai.entity.AiKbDocument;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AiKbDocumentMapper extends BaseMapper<AiKbDocument> {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.gxwebsoft.ai.mapper;
|
||||||
|
|
||||||
|
import com.gxwebsoft.ai.dto.AiShopMetricsRow;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AiShopAnalyticsMapper {
|
||||||
|
List<AiShopMetricsRow> queryMetrics(@Param("tenantId") Integer tenantId,
|
||||||
|
@Param("start") LocalDateTime start,
|
||||||
|
@Param("end") LocalDateTime end);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?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="com.gxwebsoft.ai.mapper.AiShopAnalyticsMapper">
|
||||||
|
|
||||||
|
<select id="queryMetrics" resultType="com.gxwebsoft.ai.dto.AiShopMetricsRow">
|
||||||
|
SELECT
|
||||||
|
tenant_id,
|
||||||
|
DATE_FORMAT(create_time, '%Y-%m-%d') AS day,
|
||||||
|
COUNT(1) AS order_cnt,
|
||||||
|
SUM(CASE WHEN pay_status = 1 THEN 1 ELSE 0 END) AS paid_order_cnt,
|
||||||
|
SUM(CASE WHEN pay_status = 1 THEN COALESCE(pay_price, 0) ELSE 0 END) AS gmv,
|
||||||
|
SUM(CASE WHEN COALESCE(refund_money, 0) > 0 THEN COALESCE(refund_money, 0) ELSE 0 END) AS refund_amt,
|
||||||
|
COUNT(DISTINCT IF(pay_status = 1, user_id, NULL)) AS pay_user_cnt
|
||||||
|
FROM shop_order
|
||||||
|
WHERE deleted = 0
|
||||||
|
AND tenant_id = #{tenantId}
|
||||||
|
AND create_time >= #{start}
|
||||||
|
AND create_time < #{end}
|
||||||
|
GROUP BY tenant_id, DATE_FORMAT(create_time, '%Y-%m-%d')
|
||||||
|
ORDER BY day ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
|
|
||||||
24
src/main/java/com/gxwebsoft/ai/prompt/AiPrompts.java
Normal file
24
src/main/java/com/gxwebsoft/ai/prompt/AiPrompts.java
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package com.gxwebsoft.ai.prompt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一提示词模板(尽量简短、可控)。
|
||||||
|
*/
|
||||||
|
public class AiPrompts {
|
||||||
|
private AiPrompts() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final String SYSTEM_SUPPORT =
|
||||||
|
"你是 WebSoft 客服AI。规则:\n" +
|
||||||
|
"- 只使用给定的“上下文资料”回答,禁止编造。\n" +
|
||||||
|
"- 如果资料不足,直接说“资料不足”,并列出需要补充的信息。\n" +
|
||||||
|
"- 答案末尾必须给引用,格式:[source:chunk_id]。\n" +
|
||||||
|
"- 输出中文,简洁可执行。\n";
|
||||||
|
|
||||||
|
public static final String SYSTEM_ANALYTICS =
|
||||||
|
"你是商城订单数据分析助手。你将基于提供的按天指标数据给出结论。\n" +
|
||||||
|
"要求:\n" +
|
||||||
|
"- 只基于数据陈述,不要编造不存在的数字。\n" +
|
||||||
|
"- 输出包含:结论、关键指标变化、异常点、建议的下一步核查。\n" +
|
||||||
|
"- 输出中文,简洁。\n";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.gxwebsoft.ai.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.gxwebsoft.ai.dto.*;
|
||||||
|
import com.gxwebsoft.ai.prompt.AiPrompts;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.core.utils.JSONUtil;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiAnalyticsService {
|
||||||
|
@Resource
|
||||||
|
private AiShopAnalyticsService shopAnalyticsService;
|
||||||
|
@Resource
|
||||||
|
private AiChatService aiChatService;
|
||||||
|
|
||||||
|
public AiShopMetricsQueryResult queryShopMetrics(Integer tenantId, AiShopMetricsQueryRequest request) {
|
||||||
|
if (tenantId == null) {
|
||||||
|
throw new BusinessException("tenantId 不能为空");
|
||||||
|
}
|
||||||
|
if (request == null) {
|
||||||
|
throw new BusinessException("请求不能为空");
|
||||||
|
}
|
||||||
|
LocalDate start = parseDate(request.getStartDate(), "startDate");
|
||||||
|
LocalDate end = parseDate(request.getEndDate(), "endDate");
|
||||||
|
if (end.isBefore(start)) {
|
||||||
|
throw new BusinessException("endDate 不能早于 startDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
AiShopMetricsQueryResult r = new AiShopMetricsQueryResult();
|
||||||
|
r.setTenantId(tenantId);
|
||||||
|
r.setStartDate(start.toString());
|
||||||
|
r.setEndDate(end.toString());
|
||||||
|
r.setRows(shopAnalyticsService.queryTenantDaily(tenantId, start, end));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AiAnalyticsAskResult askShopAnalytics(Integer tenantId, AiAnalyticsAskRequest request) {
|
||||||
|
if (request == null || StrUtil.isBlank(request.getQuestion())) {
|
||||||
|
throw new BusinessException("question 不能为空");
|
||||||
|
}
|
||||||
|
AiShopMetricsQueryRequest q = new AiShopMetricsQueryRequest();
|
||||||
|
q.setStartDate(request.getStartDate());
|
||||||
|
q.setEndDate(request.getEndDate());
|
||||||
|
q.setGroupByDay(true);
|
||||||
|
AiShopMetricsQueryResult data = queryShopMetrics(tenantId, q);
|
||||||
|
|
||||||
|
String userPrompt =
|
||||||
|
"用户问题:\n" + request.getQuestion() + "\n\n" +
|
||||||
|
"数据(JSON,字段含义:order_cnt=订单数,paid_order_cnt=已支付订单数,gmv=已支付金额,refund_amt=退款金额,pay_user_cnt=支付用户数,aov=客单价,pay_rate=支付率):\n" +
|
||||||
|
JSONUtil.toJSONString(data, true);
|
||||||
|
|
||||||
|
AiChatRequest chat = new AiChatRequest();
|
||||||
|
chat.setMessages(Arrays.asList(
|
||||||
|
new AiMessage("system", AiPrompts.SYSTEM_ANALYTICS),
|
||||||
|
new AiMessage("user", userPrompt)
|
||||||
|
));
|
||||||
|
|
||||||
|
AiChatResult resp = aiChatService.chat(chat);
|
||||||
|
AiAnalyticsAskResult r = new AiAnalyticsAskResult();
|
||||||
|
r.setAnalysis(resp.getContent());
|
||||||
|
r.setData(data);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate parseDate(String s, String field) {
|
||||||
|
if (StrUtil.isBlank(s)) {
|
||||||
|
throw new BusinessException(field + " 不能为空,格式 YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(s.trim());
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
throw new BusinessException(field + " 格式错误,需 YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
94
src/main/java/com/gxwebsoft/ai/service/AiChatService.java
Normal file
94
src/main/java/com/gxwebsoft/ai/service/AiChatService.java
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package com.gxwebsoft.ai.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.gxwebsoft.ai.client.OllamaClient;
|
||||||
|
import com.gxwebsoft.ai.client.dto.OllamaChatRequest;
|
||||||
|
import com.gxwebsoft.ai.client.dto.OllamaChatResponse;
|
||||||
|
import com.gxwebsoft.ai.client.dto.OllamaMessage;
|
||||||
|
import com.gxwebsoft.ai.config.AiOllamaProperties;
|
||||||
|
import com.gxwebsoft.ai.dto.AiChatRequest;
|
||||||
|
import com.gxwebsoft.ai.dto.AiChatResult;
|
||||||
|
import com.gxwebsoft.ai.dto.AiMessage;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiChatService {
|
||||||
|
@Resource
|
||||||
|
private AiOllamaProperties props;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private OllamaClient ollamaClient;
|
||||||
|
|
||||||
|
public AiChatResult chat(AiChatRequest request) {
|
||||||
|
OllamaChatRequest req = buildChatRequest(request, false);
|
||||||
|
OllamaChatResponse resp = ollamaClient.chat(req);
|
||||||
|
String content = resp != null && resp.getMessage() != null ? resp.getMessage().getContent() : null;
|
||||||
|
return new AiChatResult(content == null ? "" : content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void chatStream(AiChatRequest request, Consumer<String> onDelta, Consumer<OllamaChatResponse> onFinal) {
|
||||||
|
Objects.requireNonNull(onDelta, "onDelta");
|
||||||
|
OllamaChatRequest req = buildChatRequest(request, true);
|
||||||
|
ollamaClient.chatStream(req, event -> {
|
||||||
|
String delta = event != null && event.getMessage() != null ? event.getMessage().getContent() : null;
|
||||||
|
if (StrUtil.isNotBlank(delta)) {
|
||||||
|
onDelta.accept(delta);
|
||||||
|
}
|
||||||
|
if (Boolean.TRUE.equals(event.getDone()) && onFinal != null) {
|
||||||
|
onFinal.accept(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private OllamaChatRequest buildChatRequest(AiChatRequest request, boolean stream) {
|
||||||
|
if (request == null) {
|
||||||
|
throw new BusinessException("请求不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AiMessage> messages = request.getMessages();
|
||||||
|
if ((messages == null || messages.isEmpty()) && StrUtil.isBlank(request.getPrompt())) {
|
||||||
|
throw new BusinessException("prompt 或 messages 不能为空");
|
||||||
|
}
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
messages = Collections.singletonList(new AiMessage("user", request.getPrompt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<OllamaMessage> ollamaMessages = new ArrayList<>();
|
||||||
|
for (AiMessage m : messages) {
|
||||||
|
if (m == null || StrUtil.isBlank(m.getRole()) || m.getContent() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ollamaMessages.add(new OllamaMessage(m.getRole(), m.getContent()));
|
||||||
|
}
|
||||||
|
if (ollamaMessages.isEmpty()) {
|
||||||
|
throw new BusinessException("messages 为空或无有效内容");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> options = new HashMap<>();
|
||||||
|
if (request.getTemperature() != null) {
|
||||||
|
options.put("temperature", request.getTemperature());
|
||||||
|
}
|
||||||
|
if (request.getTopK() != null) {
|
||||||
|
options.put("top_k", request.getTopK());
|
||||||
|
}
|
||||||
|
if (request.getTopP() != null) {
|
||||||
|
options.put("top_p", request.getTopP());
|
||||||
|
}
|
||||||
|
if (request.getNumPredict() != null) {
|
||||||
|
options.put("num_predict", request.getNumPredict());
|
||||||
|
}
|
||||||
|
|
||||||
|
OllamaChatRequest req = new OllamaChatRequest();
|
||||||
|
req.setModel(StrUtil.blankToDefault(request.getModel(), props.getChatModel()));
|
||||||
|
req.setMessages(ollamaMessages);
|
||||||
|
req.setStream(stream);
|
||||||
|
req.setOptions(options.isEmpty() ? null : options);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.gxwebsoft.ai.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.gxwebsoft.ai.entity.AiKbChunk;
|
||||||
|
|
||||||
|
public interface AiKbChunkService extends IService<AiKbChunk> {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.gxwebsoft.ai.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import com.gxwebsoft.ai.entity.AiKbDocument;
|
||||||
|
|
||||||
|
public interface AiKbDocumentService extends IService<AiKbDocument> {
|
||||||
|
}
|
||||||
|
|
||||||
426
src/main/java/com/gxwebsoft/ai/service/AiKbRagService.java
Normal file
426
src/main/java/com/gxwebsoft/ai/service/AiKbRagService.java
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
package com.gxwebsoft.ai.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.gxwebsoft.ai.client.OllamaClient;
|
||||||
|
import com.gxwebsoft.ai.client.dto.OllamaEmbeddingResponse;
|
||||||
|
import com.gxwebsoft.ai.config.AiOllamaProperties;
|
||||||
|
import com.gxwebsoft.ai.dto.*;
|
||||||
|
import com.gxwebsoft.ai.entity.AiKbChunk;
|
||||||
|
import com.gxwebsoft.ai.entity.AiKbDocument;
|
||||||
|
import com.gxwebsoft.ai.prompt.AiPrompts;
|
||||||
|
import com.gxwebsoft.ai.util.AiTextUtil;
|
||||||
|
import com.gxwebsoft.cms.entity.CmsArticle;
|
||||||
|
import com.gxwebsoft.cms.entity.CmsArticleContent;
|
||||||
|
import com.gxwebsoft.cms.service.CmsArticleContentService;
|
||||||
|
import com.gxwebsoft.cms.service.CmsArticleService;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.core.utils.JSONUtil;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.apache.tika.Tika;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiKbRagService {
|
||||||
|
@Resource
|
||||||
|
private AiOllamaProperties props;
|
||||||
|
@Resource
|
||||||
|
private OllamaClient ollamaClient;
|
||||||
|
@Resource
|
||||||
|
private AiKbDocumentService documentService;
|
||||||
|
@Resource
|
||||||
|
private AiKbChunkService chunkService;
|
||||||
|
@Resource
|
||||||
|
private CmsArticleService cmsArticleService;
|
||||||
|
@Resource
|
||||||
|
private CmsArticleContentService cmsArticleContentService;
|
||||||
|
@Resource
|
||||||
|
private AiChatService aiChatService;
|
||||||
|
@Resource
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private final Tika tika = new Tika();
|
||||||
|
private volatile ExecutorService embedPool;
|
||||||
|
|
||||||
|
private ExecutorService pool() {
|
||||||
|
ExecutorService p = embedPool;
|
||||||
|
if (p != null) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
if (embedPool == null) {
|
||||||
|
embedPool = Executors.newFixedThreadPool(Math.max(1, props.getMaxConcurrency()));
|
||||||
|
}
|
||||||
|
return embedPool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public AiKbIngestResult ingestUpload(Integer tenantId, MultipartFile file) {
|
||||||
|
if (tenantId == null) {
|
||||||
|
throw new BusinessException("tenantId 不能为空");
|
||||||
|
}
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BusinessException("文件不能为空");
|
||||||
|
}
|
||||||
|
String title = StrUtil.blankToDefault(file.getOriginalFilename(), "upload");
|
||||||
|
String text = extractText(file);
|
||||||
|
if (StrUtil.isBlank(text)) {
|
||||||
|
throw new BusinessException("无法解析文件内容,请上传 txt/md/html 等可解析文本");
|
||||||
|
}
|
||||||
|
|
||||||
|
String contentHash = AiTextUtil.sha256(text);
|
||||||
|
AiKbDocument doc = new AiKbDocument();
|
||||||
|
doc.setTitle(title);
|
||||||
|
doc.setSourceType("upload");
|
||||||
|
doc.setSourceRef(title);
|
||||||
|
doc.setContentHash(contentHash);
|
||||||
|
doc.setStatus(0);
|
||||||
|
doc.setTenantId(tenantId);
|
||||||
|
doc.setCreateTime(LocalDateTime.now());
|
||||||
|
doc.setUpdateTime(LocalDateTime.now());
|
||||||
|
documentService.save(doc);
|
||||||
|
|
||||||
|
int chunks = ingestChunks(doc, text);
|
||||||
|
|
||||||
|
AiKbIngestResult r = new AiKbIngestResult();
|
||||||
|
r.setDocumentId(doc.getDocumentId());
|
||||||
|
r.setTitle(doc.getTitle());
|
||||||
|
r.setChunks(chunks);
|
||||||
|
r.setUpdatedDocuments(1);
|
||||||
|
r.setSkippedDocuments(0);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步 CMS(仅当前 tenant)。
|
||||||
|
*/
|
||||||
|
public AiKbIngestResult syncCms(Integer tenantId) {
|
||||||
|
if (tenantId == null) {
|
||||||
|
throw new BusinessException("tenantId 不能为空");
|
||||||
|
}
|
||||||
|
// 仅同步“已发布且未删除”的文章
|
||||||
|
List<CmsArticle> articles = cmsArticleService.list(new LambdaQueryWrapper<CmsArticle>()
|
||||||
|
.eq(CmsArticle::getTenantId, tenantId)
|
||||||
|
.eq(CmsArticle::getDeleted, 0)
|
||||||
|
.eq(CmsArticle::getStatus, 0));
|
||||||
|
if (articles == null || articles.isEmpty()) {
|
||||||
|
AiKbIngestResult r = new AiKbIngestResult();
|
||||||
|
r.setUpdatedDocuments(0);
|
||||||
|
r.setSkippedDocuments(0);
|
||||||
|
r.setChunks(0);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Integer> articleIds = articles.stream().map(CmsArticle::getArticleId).collect(Collectors.toSet());
|
||||||
|
List<CmsArticleContent> contents = cmsArticleContentService.list(new LambdaQueryWrapper<CmsArticleContent>()
|
||||||
|
.in(CmsArticleContent::getArticleId, articleIds));
|
||||||
|
|
||||||
|
Map<Integer, CmsArticleContent> contentByArticle = contents.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
CmsArticleContent::getArticleId,
|
||||||
|
c -> c,
|
||||||
|
(a, b) -> (a.getCreateTime() != null && b.getCreateTime() != null && a.getCreateTime().isAfter(b.getCreateTime())) ? a : b
|
||||||
|
));
|
||||||
|
|
||||||
|
int updatedDocs = 0;
|
||||||
|
int skippedDocs = 0;
|
||||||
|
int totalChunks = 0;
|
||||||
|
|
||||||
|
for (CmsArticle a : articles) {
|
||||||
|
CmsArticleContent c = contentByArticle.get(a.getArticleId());
|
||||||
|
String raw = "";
|
||||||
|
if (a.getOverview() != null) {
|
||||||
|
raw += a.getOverview() + "\n";
|
||||||
|
}
|
||||||
|
if (c != null && c.getContent() != null) {
|
||||||
|
raw += c.getContent();
|
||||||
|
}
|
||||||
|
String text = a.getTitle() + "\n" + AiTextUtil.stripHtml(raw);
|
||||||
|
text = AiTextUtil.normalizeWhitespace(text);
|
||||||
|
if (StrUtil.isBlank(text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String hash = AiTextUtil.sha256(text);
|
||||||
|
|
||||||
|
AiKbDocument existing = documentService.getOne(new LambdaQueryWrapper<AiKbDocument>()
|
||||||
|
.eq(AiKbDocument::getTenantId, tenantId)
|
||||||
|
.eq(AiKbDocument::getSourceType, "cms")
|
||||||
|
.eq(AiKbDocument::getSourceId, a.getArticleId())
|
||||||
|
.last("limit 1"));
|
||||||
|
|
||||||
|
if (existing != null && StrUtil.equals(existing.getContentHash(), hash)) {
|
||||||
|
skippedDocs++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AiKbDocument doc;
|
||||||
|
if (existing == null) {
|
||||||
|
doc = new AiKbDocument();
|
||||||
|
doc.setTitle(a.getTitle());
|
||||||
|
doc.setSourceType("cms");
|
||||||
|
doc.setSourceId(a.getArticleId());
|
||||||
|
doc.setSourceRef(a.getCode());
|
||||||
|
doc.setContentHash(hash);
|
||||||
|
doc.setStatus(0);
|
||||||
|
doc.setTenantId(tenantId);
|
||||||
|
doc.setCreateTime(LocalDateTime.now());
|
||||||
|
doc.setUpdateTime(LocalDateTime.now());
|
||||||
|
documentService.save(doc);
|
||||||
|
} else {
|
||||||
|
doc = existing;
|
||||||
|
doc.setTitle(a.getTitle());
|
||||||
|
doc.setSourceRef(a.getCode());
|
||||||
|
doc.setContentHash(hash);
|
||||||
|
doc.setUpdateTime(LocalDateTime.now());
|
||||||
|
documentService.updateById(doc);
|
||||||
|
// 重新入库:先删除旧 chunk
|
||||||
|
chunkService.remove(new LambdaQueryWrapper<AiKbChunk>().eq(AiKbChunk::getDocumentId, doc.getDocumentId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
int chunks = ingestChunks(doc, text);
|
||||||
|
totalChunks += chunks;
|
||||||
|
updatedDocs++;
|
||||||
|
}
|
||||||
|
|
||||||
|
AiKbIngestResult r = new AiKbIngestResult();
|
||||||
|
r.setUpdatedDocuments(updatedDocs);
|
||||||
|
r.setSkippedDocuments(skippedDocs);
|
||||||
|
r.setChunks(totalChunks);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AiKbQueryResult query(Integer tenantId, AiKbQueryRequest request) {
|
||||||
|
if (tenantId == null) {
|
||||||
|
throw new BusinessException("tenantId 不能为空");
|
||||||
|
}
|
||||||
|
if (request == null || StrUtil.isBlank(request.getQuery())) {
|
||||||
|
throw new BusinessException("query 不能为空");
|
||||||
|
}
|
||||||
|
int topK = request.getTopK() != null ? request.getTopK() : props.getRagTopK();
|
||||||
|
topK = Math.max(1, Math.min(20, topK));
|
||||||
|
|
||||||
|
float[] qEmb = embedding(request.getQuery());
|
||||||
|
float qNorm = l2(qEmb);
|
||||||
|
if (qNorm == 0f) {
|
||||||
|
throw new BusinessException("query embedding 为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MVP:按 tenant 拉取最新 N 条候选 chunk,再做余弦相似度排序
|
||||||
|
List<AiKbChunk> candidates = chunkService.list(new LambdaQueryWrapper<AiKbChunk>()
|
||||||
|
.eq(AiKbChunk::getTenantId, tenantId)
|
||||||
|
.orderByDesc(AiKbChunk::getId)
|
||||||
|
.last("limit " + props.getRagMaxCandidates()));
|
||||||
|
|
||||||
|
PriorityQueue<AiKbHit> pq = new PriorityQueue<>(Comparator.comparingDouble(h -> h.getScore() == null ? -1d : h.getScore()));
|
||||||
|
for (AiKbChunk c : candidates) {
|
||||||
|
if (StrUtil.isBlank(c.getEmbedding())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
float[] cEmb = parseEmbedding(c.getEmbedding());
|
||||||
|
if (cEmb == null || cEmb.length == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Double cNormD = c.getEmbeddingNorm();
|
||||||
|
float cNorm = cNormD == null ? l2(cEmb) : cNormD.floatValue();
|
||||||
|
if (cNorm == 0f) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double score = dot(qEmb, cEmb) / (qNorm * cNorm);
|
||||||
|
AiKbHit hit = new AiKbHit();
|
||||||
|
hit.setChunkId(c.getChunkId());
|
||||||
|
hit.setDocumentId(c.getDocumentId());
|
||||||
|
hit.setTitle(StrUtil.blankToDefault(c.getTitle(), ""));
|
||||||
|
hit.setScore(score);
|
||||||
|
// 返回给前端时避免过长
|
||||||
|
hit.setContent(clip(c.getContent(), 900));
|
||||||
|
|
||||||
|
if (pq.size() < topK) {
|
||||||
|
pq.add(hit);
|
||||||
|
} else if (hit.getScore() != null && hit.getScore() > pq.peek().getScore()) {
|
||||||
|
pq.poll();
|
||||||
|
pq.add(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AiKbHit> hits = new ArrayList<>(pq);
|
||||||
|
hits.sort((a, b) -> Double.compare(b.getScore(), a.getScore()));
|
||||||
|
AiKbQueryResult r = new AiKbQueryResult();
|
||||||
|
r.setHits(hits);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AiKbAskResult ask(Integer tenantId, AiKbAskRequest request) {
|
||||||
|
if (request == null || StrUtil.isBlank(request.getQuestion())) {
|
||||||
|
throw new BusinessException("question 不能为空");
|
||||||
|
}
|
||||||
|
AiKbQueryRequest q = new AiKbQueryRequest();
|
||||||
|
q.setQuery(request.getQuestion());
|
||||||
|
q.setTopK(request.getTopK());
|
||||||
|
AiKbQueryResult retrieval = query(tenantId, q);
|
||||||
|
|
||||||
|
String context = buildContext(retrieval);
|
||||||
|
String userPrompt = "上下文资料:\n" + context + "\n\n用户问题:\n" + request.getQuestion();
|
||||||
|
|
||||||
|
AiChatRequest chatReq = new AiChatRequest();
|
||||||
|
chatReq.setMessages(Arrays.asList(
|
||||||
|
new AiMessage("system", AiPrompts.SYSTEM_SUPPORT),
|
||||||
|
new AiMessage("user", userPrompt)
|
||||||
|
));
|
||||||
|
AiChatResult chat = aiChatService.chat(chatReq);
|
||||||
|
|
||||||
|
AiKbAskResult r = new AiKbAskResult();
|
||||||
|
r.setAnswer(chat.getContent());
|
||||||
|
r.setRetrieval(retrieval);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ingestChunks(AiKbDocument doc, String text) {
|
||||||
|
List<String> chunks = AiTextUtil.chunkText(text, props.getRagChunkSize(), props.getRagChunkOverlap());
|
||||||
|
if (chunks.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
List<CompletableFuture<AiKbChunk>> futures = new ArrayList<>(chunks.size());
|
||||||
|
for (int i = 0; i < chunks.size(); i++) {
|
||||||
|
final int idx = i;
|
||||||
|
final String chunkText = chunks.get(i);
|
||||||
|
futures.add(CompletableFuture.supplyAsync(() -> {
|
||||||
|
OllamaEmbeddingResponse emb = ollamaClient.embedding(chunkText);
|
||||||
|
if (emb == null || emb.getEmbedding() == null || emb.getEmbedding().isEmpty()) {
|
||||||
|
throw new BusinessException("embedding 生成失败");
|
||||||
|
}
|
||||||
|
float[] v = toFloat(emb.getEmbedding());
|
||||||
|
float norm = l2(v);
|
||||||
|
AiKbChunk c = new AiKbChunk();
|
||||||
|
c.setDocumentId(doc.getDocumentId());
|
||||||
|
c.setChunkId(UUID.randomUUID().toString().replace("-", ""));
|
||||||
|
c.setChunkIndex(idx);
|
||||||
|
c.setTitle(doc.getTitle());
|
||||||
|
c.setContent(chunkText);
|
||||||
|
c.setContentHash(AiTextUtil.sha256(chunkText));
|
||||||
|
c.setEmbedding(JSONUtil.toJSONString(emb.getEmbedding()));
|
||||||
|
c.setEmbeddingNorm((double) norm);
|
||||||
|
c.setTenantId(doc.getTenantId());
|
||||||
|
c.setCreateTime(now);
|
||||||
|
c.setDeleted(0);
|
||||||
|
return c;
|
||||||
|
}, pool()));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AiKbChunk> entities = futures.stream().map(f -> {
|
||||||
|
try {
|
||||||
|
return f.get(10, TimeUnit.MINUTES);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("embedding 批处理失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
chunkService.saveBatch(entities);
|
||||||
|
return entities.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractText(MultipartFile file) {
|
||||||
|
try {
|
||||||
|
String contentType = file.getContentType();
|
||||||
|
String filename = file.getOriginalFilename();
|
||||||
|
|
||||||
|
// 优先:对纯文本直接读 UTF-8
|
||||||
|
if ((contentType != null && contentType.startsWith("text/"))
|
||||||
|
|| (filename != null && (filename.endsWith(".txt") || filename.endsWith(".md") || filename.endsWith(".html") || filename.endsWith(".htm")))) {
|
||||||
|
return AiTextUtil.normalizeWhitespace(new String(file.getBytes(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试用 tika 解析(注意:当前依赖为 tika-core,解析能力有限)
|
||||||
|
String parsed = tika.parseToString(file.getInputStream());
|
||||||
|
return AiTextUtil.normalizeWhitespace(parsed);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float[] embedding(String text) {
|
||||||
|
OllamaEmbeddingResponse emb = ollamaClient.embedding(text);
|
||||||
|
if (emb == null || emb.getEmbedding() == null || emb.getEmbedding().isEmpty()) {
|
||||||
|
throw new BusinessException("embedding 生成失败");
|
||||||
|
}
|
||||||
|
return toFloat(emb.getEmbedding());
|
||||||
|
}
|
||||||
|
|
||||||
|
private float[] parseEmbedding(String json) {
|
||||||
|
try {
|
||||||
|
// embedding 是一维数组,存储为 JSON 文本
|
||||||
|
double[] d = ObjectUtil.isEmpty(json) ? null : objectMapper.readValue(json, double[].class);
|
||||||
|
if (d == null || d.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
float[] f = new float[d.length];
|
||||||
|
for (int i = 0; i < d.length; i++) {
|
||||||
|
f[i] = (float) d[i];
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] toFloat(List<Double> v) {
|
||||||
|
float[] out = new float[v.size()];
|
||||||
|
for (int i = 0; i < v.size(); i++) {
|
||||||
|
Double d = v.get(i);
|
||||||
|
out[i] = d == null ? 0f : d.floatValue();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float dot(float[] a, float[] b) {
|
||||||
|
int n = Math.min(a.length, b.length);
|
||||||
|
double s = 0d;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
s += (double) a[i] * (double) b[i];
|
||||||
|
}
|
||||||
|
return (float) s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float l2(float[] a) {
|
||||||
|
double s = 0d;
|
||||||
|
for (float v : a) {
|
||||||
|
s += (double) v * (double) v;
|
||||||
|
}
|
||||||
|
return (float) Math.sqrt(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String clip(String s, int max) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (s.length() <= max) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return s.substring(0, max) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildContext(AiKbQueryResult retrieval) {
|
||||||
|
if (retrieval == null || retrieval.getHits() == null || retrieval.getHits().isEmpty()) {
|
||||||
|
return "(无)";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (AiKbHit h : retrieval.getHits()) {
|
||||||
|
sb.append("[source:").append(h.getChunkId()).append("] ");
|
||||||
|
if (StrUtil.isNotBlank(h.getTitle())) {
|
||||||
|
sb.append(h.getTitle()).append("\n");
|
||||||
|
}
|
||||||
|
sb.append(h.getContent()).append("\n\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.gxwebsoft.ai.service;
|
||||||
|
|
||||||
|
import com.gxwebsoft.ai.dto.AiShopMetricsRow;
|
||||||
|
import com.gxwebsoft.ai.mapper.AiShopAnalyticsMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiShopAnalyticsService {
|
||||||
|
@Resource
|
||||||
|
private AiShopAnalyticsMapper mapper;
|
||||||
|
|
||||||
|
public List<AiShopMetricsRow> queryTenantDaily(Integer tenantId, LocalDate startDate, LocalDate endDateInclusive) {
|
||||||
|
LocalDateTime start = startDate.atStartOfDay();
|
||||||
|
LocalDateTime endExclusive = endDateInclusive.plusDays(1).atStartOfDay();
|
||||||
|
List<AiShopMetricsRow> rows = mapper.queryMetrics(tenantId, start, endExclusive);
|
||||||
|
if (rows == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (AiShopMetricsRow r : rows) {
|
||||||
|
long orderCnt = r.getOrderCnt() == null ? 0L : r.getOrderCnt();
|
||||||
|
long paidCnt = r.getPaidOrderCnt() == null ? 0L : r.getPaidOrderCnt();
|
||||||
|
BigDecimal gmv = r.getGmv() == null ? BigDecimal.ZERO : r.getGmv();
|
||||||
|
|
||||||
|
if (paidCnt > 0) {
|
||||||
|
r.setAov(gmv.divide(BigDecimal.valueOf(paidCnt), 4, RoundingMode.HALF_UP));
|
||||||
|
} else {
|
||||||
|
r.setAov(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
if (orderCnt > 0) {
|
||||||
|
r.setPayRate(BigDecimal.valueOf(paidCnt)
|
||||||
|
.divide(BigDecimal.valueOf(orderCnt), 4, RoundingMode.HALF_UP));
|
||||||
|
} else {
|
||||||
|
r.setPayRate(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.gxwebsoft.ai.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.gxwebsoft.ai.entity.AiKbChunk;
|
||||||
|
import com.gxwebsoft.ai.mapper.AiKbChunkMapper;
|
||||||
|
import com.gxwebsoft.ai.service.AiKbChunkService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiKbChunkServiceImpl extends ServiceImpl<AiKbChunkMapper, AiKbChunk> implements AiKbChunkService {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.gxwebsoft.ai.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.gxwebsoft.ai.entity.AiKbDocument;
|
||||||
|
import com.gxwebsoft.ai.mapper.AiKbDocumentMapper;
|
||||||
|
import com.gxwebsoft.ai.service.AiKbDocumentService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiKbDocumentServiceImpl extends ServiceImpl<AiKbDocumentMapper, AiKbDocument> implements AiKbDocumentService {
|
||||||
|
}
|
||||||
|
|
||||||
100
src/main/java/com/gxwebsoft/ai/util/AiTextUtil.java
Normal file
100
src/main/java/com/gxwebsoft/ai/util/AiTextUtil.java
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package com.gxwebsoft.ai.util;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class AiTextUtil {
|
||||||
|
private AiTextUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sha256(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] dig = md.digest(s.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder sb = new StringBuilder(dig.length * 2);
|
||||||
|
for (byte b : dig) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 很轻量的 HTML 转纯文本(不追求完美,只用于知识库入库前清洗)。
|
||||||
|
*/
|
||||||
|
public static String stripHtml(String html) {
|
||||||
|
if (StrUtil.isBlank(html)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = html;
|
||||||
|
s = s.replaceAll("(?is)<script[^>]*>.*?</script>", " ");
|
||||||
|
s = s.replaceAll("(?is)<style[^>]*>.*?</style>", " ");
|
||||||
|
s = s.replaceAll("(?is)<br\\s*/?>", "\n");
|
||||||
|
s = s.replaceAll("(?is)</p\\s*>", "\n");
|
||||||
|
s = s.replaceAll("(?is)<[^>]+>", " ");
|
||||||
|
// 常见 HTML 实体最小处理
|
||||||
|
s = s.replace(" ", " ");
|
||||||
|
s = s.replace("<", "<").replace(">", ">").replace("&", "&").replace(""", "\"");
|
||||||
|
return normalizeWhitespace(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalizeWhitespace(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// 合并空白,保留换行用于 chunking
|
||||||
|
String x = s.replace("\r", "\n");
|
||||||
|
x = x.replaceAll("[\\t\\f\\u000B]+", " ");
|
||||||
|
x = x.replaceAll("[ ]{2,}", " ");
|
||||||
|
x = x.replaceAll("\\n{3,}", "\n\n");
|
||||||
|
return x.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按字符数切分,并做固定 overlap。
|
||||||
|
*/
|
||||||
|
public static List<String> chunkText(String text, int chunkSize, int overlap) {
|
||||||
|
String s = normalizeWhitespace(text);
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (StrUtil.isBlank(s)) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (chunkSize <= 0) {
|
||||||
|
chunkSize = 800;
|
||||||
|
}
|
||||||
|
if (overlap < 0) {
|
||||||
|
overlap = 0;
|
||||||
|
}
|
||||||
|
if (overlap >= chunkSize) {
|
||||||
|
overlap = Math.max(0, chunkSize / 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
int n = s.length();
|
||||||
|
int start = 0;
|
||||||
|
while (start < n) {
|
||||||
|
int end = Math.min(n, start + chunkSize);
|
||||||
|
String chunk = s.substring(start, end).trim();
|
||||||
|
if (!chunk.isEmpty()) {
|
||||||
|
out.add(chunk);
|
||||||
|
}
|
||||||
|
if (end >= n) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
start = end - overlap;
|
||||||
|
if (start < 0) {
|
||||||
|
start = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ server:
|
|||||||
# 多环境配置
|
# 多环境配置
|
||||||
spring:
|
spring:
|
||||||
profiles:
|
profiles:
|
||||||
active: ysb2
|
active: dev
|
||||||
|
|
||||||
application:
|
application:
|
||||||
name: server
|
name: server
|
||||||
@@ -201,6 +201,22 @@ springdoc:
|
|||||||
swagger-ui:
|
swagger-ui:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
# AI 模块(Ollama)
|
||||||
|
ai:
|
||||||
|
ollama:
|
||||||
|
base-url: https://ai-api.websoft.top
|
||||||
|
fallback-url: http://47.119.165.234:11434
|
||||||
|
chat-model: qwen3.5:cloud
|
||||||
|
embed-model: qwen3-embedding:4b
|
||||||
|
connect-timeout-ms: 10000
|
||||||
|
read-timeout-ms: 300000
|
||||||
|
write-timeout-ms: 60000
|
||||||
|
max-concurrency: 4
|
||||||
|
rag-max-candidates: 2000
|
||||||
|
rag-top-k: 5
|
||||||
|
rag-chunk-size: 800
|
||||||
|
rag-chunk-overlap: 120
|
||||||
|
|
||||||
# LED - 排班接口(业务中台)对接配置
|
# LED - 排班接口(业务中台)对接配置
|
||||||
led:
|
led:
|
||||||
bme:
|
bme:
|
||||||
|
|||||||
BIN
websoft-modules.log.2026-02-24.0.gz
Normal file
BIN
websoft-modules.log.2026-02-24.0.gz
Normal file
Binary file not shown.
Reference in New Issue
Block a user