diff --git a/docs/ai/README.md b/docs/ai/README.md new file mode 100644 index 0000000..29bc1b3 --- /dev/null +++ b/docs/ai/README.md @@ -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" +} +``` diff --git a/docs/ai/ai_kb_tables.sql b/docs/ai/ai_kb_tables.sql new file mode 100644 index 0000000..39d70dd --- /dev/null +++ b/docs/ai/ai_kb_tables.sql @@ -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'; + diff --git a/src/main/java/com/gxwebsoft/ai/client/OllamaClient.java b/src/main/java/com/gxwebsoft/ai/client/OllamaClient.java new file mode 100644 index 0000000..6e08d1b --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/client/OllamaClient.java @@ -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 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 getJson(String path, Class 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 getJsonOnce(String base, String path, Class 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 postJson(String path, Object payload, Class 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 postJsonOnce(String base, String path, String json, Class 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; + } +} + diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatRequest.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatRequest.java new file mode 100644 index 0000000..938894a --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatRequest.java @@ -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 messages; + private Boolean stream; + + /** + * Ollama options,例如:temperature、top_k、top_p、num_predict... + */ + private Map options; +} + diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatResponse.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatResponse.java new file mode 100644 index 0000000..ec9291b --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaChatResponse.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingRequest.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingRequest.java new file mode 100644 index 0000000..5629eba --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingRequest.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingResponse.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingResponse.java new file mode 100644 index 0000000..233e863 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaEmbeddingResponse.java @@ -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 embedding; +} + diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaMessage.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaMessage.java new file mode 100644 index 0000000..6606c69 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaMessage.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/client/dto/OllamaTagsResponse.java b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaTagsResponse.java new file mode 100644 index 0000000..c1fe7c0 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/client/dto/OllamaTagsResponse.java @@ -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 models; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Model { + private String name; + private Long size; + private String digest; + private String modified_at; + } +} + diff --git a/src/main/java/com/gxwebsoft/ai/config/AiOllamaProperties.java b/src/main/java/com/gxwebsoft/ai/config/AiOllamaProperties.java new file mode 100644 index 0000000..e0c1732 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/config/AiOllamaProperties.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiAnalyticsController.java b/src/main/java/com/gxwebsoft/ai/controller/AiAnalyticsController.java new file mode 100644 index 0000000..fe72b49 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/controller/AiAnalyticsController.java @@ -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 query(@RequestBody AiShopMetricsQueryRequest request) { + Integer tenantId = getTenantId(); + return success(analyticsService.queryShopMetrics(tenantId, request)); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "AI 解析并输出订单分析结论(当前租户)") + @PostMapping("/ask") + public ApiResult ask(@RequestBody AiAnalyticsAskRequest request) { + Integer tenantId = getTenantId(); + return success(analyticsService.askShopAnalytics(tenantId, request)); + } +} + diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiChatController.java b/src/main/java/com/gxwebsoft/ai/controller/AiChatController.java new file mode 100644 index 0000000..45d5564 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/controller/AiChatController.java @@ -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 models() { + return success(ollamaClient.tags()); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "非流式对话") + @PostMapping("/chat") + public ApiResult 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 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); + } +} diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiKbController.java b/src/main/java/com/gxwebsoft/ai/controller/AiKbController.java new file mode 100644 index 0000000..afd2078 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/controller/AiKbController.java @@ -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 upload(@RequestParam("file") MultipartFile file) { + Integer tenantId = getTenantId(); + return success(ragService.ingestUpload(tenantId, file)); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "同步 CMS 文章到知识库(当前租户)") + @PostMapping("/sync/cms") + public ApiResult syncCms() { + Integer tenantId = getTenantId(); + return success(ragService.syncCms(tenantId)); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "仅检索(返回 topK 命中)") + @PostMapping("/query") + public ApiResult query(@RequestBody AiKbQueryRequest request) { + Integer tenantId = getTenantId(); + return success(ragService.query(tenantId, request)); + } + + @PreAuthorize("isAuthenticated()") + @Operation(summary = "知识库问答(RAG 生成 + 返回检索结果)") + @PostMapping("/ask") + public ApiResult ask(@RequestBody AiKbAskRequest request) { + Integer tenantId = getTenantId(); + return success(ragService.ask(tenantId, request)); + } +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskRequest.java new file mode 100644 index 0000000..085472a --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskRequest.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskResult.java new file mode 100644 index 0000000..f11b61d --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiAnalyticsAskResult.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiChatRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiChatRequest.java new file mode 100644 index 0000000..eaa90ff --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiChatRequest.java @@ -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 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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiChatResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiChatResult.java new file mode 100644 index 0000000..c38dd41 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiChatResult.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbAskRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbAskRequest.java new file mode 100644 index 0000000..199134c --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbAskRequest.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbAskResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbAskResult.java new file mode 100644 index 0000000..a23a480 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbAskResult.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbHit.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbHit.java new file mode 100644 index 0000000..e204685 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbHit.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbIngestResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbIngestResult.java new file mode 100644 index 0000000..c14778d --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbIngestResult.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryRequest.java new file mode 100644 index 0000000..c4ad3c6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryRequest.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryResult.java new file mode 100644 index 0000000..33b082e --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiKbQueryResult.java @@ -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 hits; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiMessage.java b/src/main/java/com/gxwebsoft/ai/dto/AiMessage.java new file mode 100644 index 0000000..1e9ba61 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiMessage.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryRequest.java b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryRequest.java new file mode 100644 index 0000000..ba31384 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryRequest.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryResult.java b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryResult.java new file mode 100644 index 0000000..f4086cc --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsQueryResult.java @@ -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 rows; +} + diff --git a/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsRow.java b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsRow.java new file mode 100644 index 0000000..747ecdf --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/dto/AiShopMetricsRow.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/entity/AiKbChunk.java b/src/main/java/com/gxwebsoft/ai/entity/AiKbChunk.java new file mode 100644 index 0000000..47ffd0c --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/entity/AiKbChunk.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/entity/AiKbDocument.java b/src/main/java/com/gxwebsoft/ai/entity/AiKbDocument.java new file mode 100644 index 0000000..dac0c3b --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/entity/AiKbDocument.java @@ -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; +} + diff --git a/src/main/java/com/gxwebsoft/ai/mapper/AiKbChunkMapper.java b/src/main/java/com/gxwebsoft/ai/mapper/AiKbChunkMapper.java new file mode 100644 index 0000000..1628de5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/mapper/AiKbChunkMapper.java @@ -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 { +} + diff --git a/src/main/java/com/gxwebsoft/ai/mapper/AiKbDocumentMapper.java b/src/main/java/com/gxwebsoft/ai/mapper/AiKbDocumentMapper.java new file mode 100644 index 0000000..8dc61c6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/mapper/AiKbDocumentMapper.java @@ -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 { +} + diff --git a/src/main/java/com/gxwebsoft/ai/mapper/AiShopAnalyticsMapper.java b/src/main/java/com/gxwebsoft/ai/mapper/AiShopAnalyticsMapper.java new file mode 100644 index 0000000..27221ac --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/mapper/AiShopAnalyticsMapper.java @@ -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 queryMetrics(@Param("tenantId") Integer tenantId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); +} + diff --git a/src/main/java/com/gxwebsoft/ai/mapper/xml/AiShopAnalyticsMapper.xml b/src/main/java/com/gxwebsoft/ai/mapper/xml/AiShopAnalyticsMapper.xml new file mode 100644 index 0000000..0e745b6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/mapper/xml/AiShopAnalyticsMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/ai/prompt/AiPrompts.java b/src/main/java/com/gxwebsoft/ai/prompt/AiPrompts.java new file mode 100644 index 0000000..106ae56 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/prompt/AiPrompts.java @@ -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"; +} + diff --git a/src/main/java/com/gxwebsoft/ai/service/AiAnalyticsService.java b/src/main/java/com/gxwebsoft/ai/service/AiAnalyticsService.java new file mode 100644 index 0000000..0f37aec --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/AiAnalyticsService.java @@ -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"); + } + } +} + diff --git a/src/main/java/com/gxwebsoft/ai/service/AiChatService.java b/src/main/java/com/gxwebsoft/ai/service/AiChatService.java new file mode 100644 index 0000000..c457607 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/AiChatService.java @@ -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 onDelta, Consumer 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 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 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 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; + } +} + diff --git a/src/main/java/com/gxwebsoft/ai/service/AiKbChunkService.java b/src/main/java/com/gxwebsoft/ai/service/AiKbChunkService.java new file mode 100644 index 0000000..0bcc717 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/AiKbChunkService.java @@ -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 { +} + diff --git a/src/main/java/com/gxwebsoft/ai/service/AiKbDocumentService.java b/src/main/java/com/gxwebsoft/ai/service/AiKbDocumentService.java new file mode 100644 index 0000000..601f778 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/AiKbDocumentService.java @@ -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 { +} + diff --git a/src/main/java/com/gxwebsoft/ai/service/AiKbRagService.java b/src/main/java/com/gxwebsoft/ai/service/AiKbRagService.java new file mode 100644 index 0000000..04c6c1f --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/AiKbRagService.java @@ -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 articles = cmsArticleService.list(new LambdaQueryWrapper() + .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 articleIds = articles.stream().map(CmsArticle::getArticleId).collect(Collectors.toSet()); + List contents = cmsArticleContentService.list(new LambdaQueryWrapper() + .in(CmsArticleContent::getArticleId, articleIds)); + + Map 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() + .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().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 candidates = chunkService.list(new LambdaQueryWrapper() + .eq(AiKbChunk::getTenantId, tenantId) + .orderByDesc(AiKbChunk::getId) + .last("limit " + props.getRagMaxCandidates())); + + PriorityQueue 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 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 chunks = AiTextUtil.chunkText(text, props.getRagChunkSize(), props.getRagChunkOverlap()); + if (chunks.isEmpty()) { + return 0; + } + LocalDateTime now = LocalDateTime.now(); + + List> 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 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 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(); + } +} diff --git a/src/main/java/com/gxwebsoft/ai/service/AiShopAnalyticsService.java b/src/main/java/com/gxwebsoft/ai/service/AiShopAnalyticsService.java new file mode 100644 index 0000000..09aad90 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/AiShopAnalyticsService.java @@ -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 queryTenantDaily(Integer tenantId, LocalDate startDate, LocalDate endDateInclusive) { + LocalDateTime start = startDate.atStartOfDay(); + LocalDateTime endExclusive = endDateInclusive.plusDays(1).atStartOfDay(); + List 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; + } +} + diff --git a/src/main/java/com/gxwebsoft/ai/service/impl/AiKbChunkServiceImpl.java b/src/main/java/com/gxwebsoft/ai/service/impl/AiKbChunkServiceImpl.java new file mode 100644 index 0000000..de9e157 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/impl/AiKbChunkServiceImpl.java @@ -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 implements AiKbChunkService { +} + diff --git a/src/main/java/com/gxwebsoft/ai/service/impl/AiKbDocumentServiceImpl.java b/src/main/java/com/gxwebsoft/ai/service/impl/AiKbDocumentServiceImpl.java new file mode 100644 index 0000000..7295e96 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/service/impl/AiKbDocumentServiceImpl.java @@ -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 implements AiKbDocumentService { +} + diff --git a/src/main/java/com/gxwebsoft/ai/util/AiTextUtil.java b/src/main/java/com/gxwebsoft/ai/util/AiTextUtil.java new file mode 100644 index 0000000..d7044f6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/ai/util/AiTextUtil.java @@ -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)]*>.*?", " "); + s = s.replaceAll("(?is)]*>.*?", " "); + s = s.replaceAll("(?is)", "\n"); + s = s.replaceAll("(?is)", "\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 chunkText(String text, int chunkSize, int overlap) { + String s = normalizeWhitespace(text); + List 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; + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f1a8a82..ada4ffe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/websoft-modules.log.2026-02-24.0.gz b/websoft-modules.log.2026-02-24.0.gz new file mode 100644 index 0000000..c6ac469 Binary files /dev/null and b/websoft-modules.log.2026-02-24.0.gz differ