feat(ai): 新增AI模块功能

- 添加Ollama配置参数,包括基础URL、模型设置、超时配置等
- 创建AI知识库相关数据库表(文档表和分段表)
- 实现AI数据分析服务,支持订单数据查询和分析
- 开发AI聊天控制器,提供模型列表、对话和流式对话功能
- 构建知识库RAG服务,支持文档上传、CMS同步和问答功能
- 添加多种AI相关的DTO类和实体类
- 实现AI嵌入向量计算和相似度匹配算法
- 集成Tika用于文档内容提取和解析
This commit is contained in:
2026-02-28 08:30:48 +08:00
parent 3cadaab214
commit 1c78fdbef4
45 changed files with 1888 additions and 1 deletions

61
docs/ai/README.md Normal file
View 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
View 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';

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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;
/**
* RAGchunk 重叠字符数(用于减少语义断裂)。
*/
private int ragChunkOverlap = 120;
}

View File

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

View File

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

View File

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

View File

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

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

View 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 风格 messagesrole: 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;
}

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

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

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

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

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

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

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

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

View File

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

View File

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

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

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

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

View 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> {
}

View File

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

View File

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

View File

@@ -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) &gt; 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 &gt;= #{start}
AND create_time &lt; #{end}
GROUP BY tenant_id, DATE_FORMAT(create_time, '%Y-%m-%d')
ORDER BY day ASC
</select>
</mapper>

View 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";
}

View File

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

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

View File

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

View File

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

View 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();
}
}

View File

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

View File

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

View File

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

View 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("&nbsp;", " ");
s = s.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&").replace("&quot;", "\"");
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;
}
}

View File

@@ -4,7 +4,7 @@ server:
# 多环境配置
spring:
profiles:
active: ysb2
active: dev
application:
name: server
@@ -201,6 +201,22 @@ springdoc:
swagger-ui:
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:
bme:

Binary file not shown.