diff --git a/debug-knowledge-chat-401.md b/debug-knowledge-chat-401.md new file mode 100644 index 0000000..b32f20e --- /dev/null +++ b/debug-knowledge-chat-401.md @@ -0,0 +1,52 @@ +# Debug Session: knowledge-chat-401 +- **Status**: [OPEN] +- **Issue**: 04 知识库应用发送问题后,原先会看到 `LLM 返回 401: {"error":{"message":"No cookie auth credentials found","code":401}}`;修复目标是让问题路由正确,并且不要把底层模型鉴权报错直接暴露给前台。 +- **Debug Server**: http://127.0.0.1:7777/event +- **Log File**: .dbg/trae-debug-log-knowledge-chat-401.ndjson + +## Reproduction Steps +1. 启动前端、Go 后端与知识服务。 +2. 打开 `04 知识库应用`。 +3. 发送闲聊、FAQ、总结类问题,例如: + - `给我讲个小故事` + - `你好,你是怎么工作的` + - `请根据知识库总结规则制度库里关于审批的要点` + +## Hypotheses & Verification +| ID | Hypothesis | Likelihood | Effort | Evidence | +|----|------------|------------|--------|----------| +| A | 后端把知识服务或模型服务地址调错了 | High | Low | Rejected | +| B | 当前运行进程不是最新版本 | Medium | Low | Rejected | +| C | LLM 兜底层命中了未配置 API Key 的 OpenRouter 路由,所以把 401 直接抛给了前台 | High | Low | Confirmed | +| D | 第一层分类器把闲聊 / 总结类问题分错了,所以问题走错层级 | High | Low | Confirmed | + +## Instrumentation Plan +- A: `frontend/src/api/ai.js` 记录请求入口与非 200 响应 +- B: `backend-go/internal/api/ai_chat.go` 记录聊天入口、四层计划、LLM 路由与错误 +- C: `backend-go/internal/ai/llm.go` 记录真实下游 URL、状态码与错误体前缀 +- D: `backend-go/internal/ai/knowledge_service_client.go` 记录知识服务调用状态 + +## Log Evidence +- 知识服务稳定命中本地 `http://127.0.0.1:10233/classify` 和 `/search` +- 分类修复后: + - `给我讲个小故事` -> `intent=smalltalk`、`layer=bert_classifier` + - `你好,你是怎么工作的` -> `intent=smalltalk`、`layer=bert_classifier` + - `请根据知识库总结规则制度库里关于审批的要点` -> `intent=document`、`layer=llm_fallback` +- 进入 `llm_fallback` 时,埋点显示: + - 目标地址:`https://openrouter.ai/api/v1/chat/completions` + - `hasAPIKey=false` + - `authHeader=false` + - 修复前下游直接返回 401 `No cookie auth credentials found` +- 修复后,同类问题不再把 401 暴露给前台,而是返回“当前没有可用 LLM 兜底服务”的业务化提示 + +## Verification Conclusion +- **pre-fix** + - 闲聊 / 总结类问题会被误路由 + - 一旦进入 `llm_fallback`,前台直接看到 OpenRouter 401 / cookie 报错 +- **post-fix** + - 第一层分类已改成规则优先,闲聊与总结类问题路由更符合预期 + - `llm_fallback` 不再直抛 401,而是返回更友好的业务化提示 +- **Remaining Blocker** + - 当前剩余不是代码 Bug,而是运行环境缺口: + - OpenRouter 未配置 API Key + - 本地 `Ollama` 未启动(`127.0.0.1:11434` refused) diff --git a/docs/01_System_Overall/README.md b/docs/01_System_Overall/README.md index 6b9a1c3..ac066a6 100644 --- a/docs/01_System_Overall/README.md +++ b/docs/01_System_Overall/README.md @@ -26,6 +26,7 @@ | `SY16_Ontology_Generalizes_Data_Actions_And_Workflows.md` | 本体如何让数据、动作、工作流一般化 | | `SY17_Workbench_UI_Wireframes.md` | 数字员工平台 UI 线框图(知识库固定 + 专员动态生长) | | `SY18_DWP_DW_ADW_Formal_Design_Contract.md` | DWP / DW / ADW 正式设计合同(供人和 AI 共用的对象基准) | +| `SY19_Universal_Digital_Worker_Product_Planning.md` | 通用数字员工平台 V1.0 产品规划(先通用、后定制的产品打法收口) | ## 当前主线关系 @@ -42,3 +43,4 @@ 11. `SY16`:解释本体为何要把数据、动作、工作流提升为统一业务表达,服务六层架构理解与对象建模 12. `SY17`:把六层架构落成数字员工平台工作台线框,明确平台不再是单一导航后台 13. `SY18`:正式定义 DWP / DW / ADW 的结构合同、字段规范和 AI 生成规则 +14. `SY19`:把当前阶段产品路线正式收口为「先通用数字员工、后行业包与企业定制」 diff --git a/docs/01_System_Overall/SY19_Universal_Digital_Worker_Product_Planning.md b/docs/01_System_Overall/SY19_Universal_Digital_Worker_Product_Planning.md new file mode 100644 index 0000000..7ceccde --- /dev/null +++ b/docs/01_System_Overall/SY19_Universal_Digital_Worker_Product_Planning.md @@ -0,0 +1,519 @@ +# SY19 — 通用数字员工平台 V1.0 产品规划 + +> **版本:V1.0 | 最后更新:2026-08-24** + +--- + +## 1. 文档目的 + +本文用于把当前项目从「知识库中心化的培训/考试平台」进一步收口为: + +**一套先通用、后定制的数字员工平台产品路线。** + +本文重点回答五个问题: + +- 当前阶段到底做什么,不做什么 +- 为什么不直接对标 WorkBuddy 的高自由度路线 +- 第一阶段应该先沉淀哪些通用数字员工 +- 现有项目哪些能力可以直接复用,哪些能力需要补齐 +- 后续如何从通用包自然演进到行业包和企业定制 + +本文属于 SY03、SY04、SY18 之后的收口文档。它不替代平台战略,而是把当前阶段的产品打法定死。 + +--- + +## 2. 当前判断 + +### 2.1 不直接做「高自由度 WorkBuddy」 + +WorkBuddy 的方向是高自由度、强定制、强执行型桌面智能体,这条路线的优势很明显,但对当前项目并不是最合适的第一步。 + +如果现在直接走这条路,会同时遇到几个问题: + +- 产品边界太大,容易从培训系统、知识库、工作台、低代码平台一路发散 +- 不同企业之间复用性弱,难形成标准交付包 +- 权限、审核、知识边界、结果质量都更难控 +- 销售和交付阶段难讲清「标准版到底能直接帮客户做什么」 + +因此,当前项目不应该先做「无限自由的 AI 办公工作台」,而应该先做: + +**标准化、可复制、可交付、可升级的通用数字员工平台。** + +### 2.2 当前阶段的产品定义 + +项目当前阶段统一定义为: + +**一个以知识库为底座、以数字员工为能力单元、面向企业通用办公场景的数字员工平台。** + +关键词有四个: + +- **知识底座**:所有数字员工都必须受知识空间、引用和权限边界约束 +- **能力单元**:面向用户的不是一个大而全 AI,而是一组职责明确的数字员工 +- **通用场景**:先覆盖跨行业共性工作,不先做重行业定制 +- **平台化交付**:可安装、可启停、可配置、可审核、可追溯 + +--- + +## 3. 当前阶段的产品原则 + +### 3.1 总原则 + +当前阶段的产品铁律只有一句话: + +**先做通用数字员工产品包,不做无限自由平台。** + +### 3.2 五条强约束 + +#### 原则 1:主界面从「问知识库」切到「把任务交给数字员工」 + +知识库仍然重要,但它应该退到后台成为数字员工的大脑底座。 + +面向用户的主叙事应改为: + +- 我有哪些数字员工 +- 每个数字员工负责什么 +- 我应该把任务交给谁 +- 它现在推进到哪一步 +- 它交付了什么 +- 依据来自哪里 +- 哪一步需要我确认 + +#### 原则 2:每个数字员工必须职责清晰、边界明确 + +每个数字员工都必须说清楚: + +- 它是谁 +- 它负责什么 +- 它不负责什么 +- 它可以访问哪些知识和资源 +- 它最后交付什么结果 + +不允许继续把多个能力混成一个「什么都能干的大助手」。 + +#### 原则 3:所有结果都要可追溯 + +这是当前项目相对 WorkBuddy 最值得保留并放大的优势。 + +所有数字员工输出都要尽量具备: + +- 命中层级 +- 原文引用 +- 来源证据 +- 任务记录 +- 审核状态 + +这既是企业可信度要求,也是后续做合规、认证、复盘、经营分析的基础。 + +#### 原则 4:先做可配置,不做完全开放式编排 + +当前阶段只开放以下几类配置: + +- 名称与描述 +- 适用部门/岗位 +- 绑定知识空间 +- 绑定模板 +- 输出格式 +- 审核人 +- 是否启用某连接器/能力 + +当前阶段不开放以下能力为一线主路径: + +- 任意工作流编排 +- 任意动作拼装 +- 自定义 DSL +- 完全开放的 Skill 开发模式 +- 无限自由的连接器联动 + +#### 原则 5:通用包优先,行业包和企业定制后置 + +第一阶段先做通用包,是为了先形成: + +- 明确的产品边界 +- 统一的数据结构 +- 统一的任务闭环 +- 统一的交付机制 +- 可复制的商业版本 + +行业包、企业定制都必须建立在这套标准底座之上。 + +--- + +## 4. 产品心智模型 + +### 4.1 平台对用户的呈现方式 + +对用户来说,平台不是一个聊天框,而应该是一组可调用的数字员工。 + +统一心智如下: + +``` +企业数字员工平台 + ├─ 专员市场:我有哪些数字员工可安装/可启用 + ├─ 专员工作区:某个数字员工怎么接任务、交付结果 + ├─ 任务中心:事项现在做到哪一步 + ├─ 交付中心:已经产出过什么结果 + └─ 知识底座:这些数字员工基于哪些可信知识在工作 +``` + +### 4.2 每个数字员工的最小定义 + +在 SY18 的「信源 / 动作 / 结果 / 权限」基础上,当前阶段进一步面向产品统一为五件套: + +1. **角色**:它是谁,负责什么 +2. **知识**:它能访问哪些知识空间、FAQ、模板、案例 +3. **动作**:它能执行哪些标准动作 +4. **交付**:它能产出什么 +5. **审核**:哪些结果需要人确认 + +可理解为: + +``` +数字员工 = 角色 + 知识 + 动作 + 交付 + 审核 +``` + +其中: + +- SY18 更偏业务前台的呈现模型 +- 本文更偏当前产品阶段的标准化定义模型 + +两者不冲突,可共同存在。 + +--- + +## 5. 第一阶段:先做 4 个通用数字员工 + +当前阶段不建议同时铺很多专员。第一批只做 4 个通用数字员工,确保每个都能真正形成闭环。 + +### 5.1 知识运营专员 + +**定位:** 负责知识资料整理、摘要、FAQ 沉淀、知识空间维护的通用专员。 + +**典型任务:** + +- 整理上传资料 +- 提取重点摘要 +- 生成 FAQ 初稿 +- 标记失效知识 +- 按主题归档 + +**交付结果:** + +- FAQ 条目 +- 知识摘要 +- 知识卡片 +- 待审核知识项 + +**为什么优先做:** + +- 与当前项目知识入库、审批、FAQ、检索能力直接衔接 +- 各行业通用 +- 能快速体现平台的知识底座价值 + +### 5.2 培训考试专员 + +**定位:** 负责岗位应学范围、题库生成、组卷、学习认证建议的通用专员。 + +**典型任务:** + +- 基于知识生成题目初稿 +- 生成岗位应学清单 +- 推荐学习路径 +- 输出岗位考试建议 +- 生成认证结果说明 + +**交付结果:** + +- 岗位学习包 +- 试卷/题目初稿 +- 认证建议 +- 补训建议 + +**为什么优先做:** + +- 与当前项目最成熟的培训、考试、岗位映射能力强复用 +- 是现有底座最快能升级成数字员工形态的一块 + +### 5.3 内容生成专员 + +**定位:** 负责结构化文档、周报、会议纪要、培训材料、FAQ 卡片生成的通用专员。 + +**典型任务:** + +- 生成会议纪要 +- 生成周报/月报 +- 输出培训讲义 +- 输出产品说明/方案初稿 +- 输出标准 FAQ 卡片 + +**交付结果:** + +- Word/Markdown/HTML 文档 +- 培训材料初稿 +- 话术/FAQ 卡片 +- 结构化摘要 + +**为什么优先做:** + +- 企业通用需求高频 +- 最容易让客户快速感知价值 +- 适合作为“任务 -> 草稿 -> 审核 -> 发布”的标准闭环示范 + +### 5.4 任务推进专员 + +**定位:** 负责任务拆解、状态推进、待确认项提醒、结果归档的通用专员。 + +**典型任务:** + +- 接收任务并拆解步骤 +- 跟踪事项状态 +- 提醒待确认项 +- 汇总风险点 +- 归档交付结果 + +**交付结果:** + +- 任务清单 +- 推进状态 +- 风险提醒 +- 交付归档包 + +**为什么优先做:** + +- 这是把聊天式 AI 拉成工作流式 AI 的关键 +- 与当前 `worker_task`、`worker_artifact` 等结构最接近 + +--- + +## 6. 当前项目能力复用判断 + +### 6.1 已经具备、可直接复用的底座 + +结合当前 PRD、PROJECT_STATE 和代码现状,以下能力已经具备较强复用价值: + +#### 1. 知识底座 + +- 素材上传、审批、入库 +- 文档切片与检索 +- FAQ / 混合检索 / 引用链路 +- 知识空间与工作台知识问答 + +#### 2. 人群与学习底座 + +- 岗位映射 +- 岗位应学清单 +- 考试蓝图 +- 错题本、证书、学习档案、能力雷达 + +#### 3. 平台雏形 + +- 专员市场 +- 控制台 +- 工作台概览 +- Studio / Market / KnowledgeHub 等前台入口 + +#### 4. 任务与交付雏形 + +- `worker_task` +- `worker_artifact` +- 工作台待办、风险、最近交付物 + +### 6.2 当前仍然偏弱、需要补齐的关键能力 + +#### 1. 数字员工统一定义仍不够收口 + +虽然已有 specialist 模型,但当前字段仍偏展示/概念堆叠,需要进一步统一成标准产品模型。 + +#### 2. 任务闭环还不够完整 + +需要把任务、执行、产出、审核、发布五个阶段拉通,而不是停留在卡片展示层。 + +#### 3. 模板中心还不够清晰 + +通用数字员工一定要有可复用的模板体系,否则很难形成跨企业复用。 + +#### 4. 安装与配置机制还要更产品化 + +需要更明确地区分: + +- 哪些专员是已安装 +- 哪些专员是试用 +- 哪些专员属于通用包 +- 哪些专员属于行业包 +- 某个专员绑定了哪些知识空间、模板、部门、岗位 + +--- + +## 7. 当前阶段必须补齐的产品闭环 + +如果要把当前项目真正升级成「通用数字员工平台」,至少要补齐以下闭环。 + +### 7.1 数字员工定义闭环 + +每个数字员工都必须明确: + +- 基本信息:名称、类型、适用范围、状态 +- 角色边界:负责什么、不负责什么 +- 知识边界:绑定哪些知识空间/FAQ/案例 +- 动作边界:可执行哪些标准动作 +- 交付边界:输出哪些标准结果 +- 审核边界:哪些需要人工确认 + +### 7.2 任务闭环 + +统一任务生命周期建议为: + +``` +新任务 -> 待执行 -> 执行中 -> 草稿待确认 -> 已确认 -> 已发布/已归档 +``` + +无论是知识整理、内容生成、培训认证还是任务推进,都应落在同一套任务状态机里。 + +### 7.3 交付闭环 + +每个交付结果都建议带上: + +- 交付类型 +- 来源任务 +- 来源数字员工 +- 版本 +- 审核状态 +- 知识依据/引用 +- 创建时间 +- 发布/归档状态 + +### 7.4 审核闭环 + +通用阶段不做复杂审批流,但必须有最小审核链: + +- 哪类产出允许直接查看 +- 哪类产出必须确认后才能发布 +- 谁是默认确认人 +- 哪些风险必须提示 + +--- + +## 8. 当前阶段明确不做的事 + +为了防止产品再次发散,以下能力在当前阶段明确不作为主目标: + +### 8.1 不做完全开放式 WorkBuddy 路线 + +不追求: + +- 用户一句话就能自由编排一切 +- 桌面级全权限执行 +- 无限开放的工作流拼装 +- 无限开放的插件/Skill 开发 + +### 8.2 不做重行业定制优先 + +不先围绕某一个行业重做数据模型、流程和页面。 + +当前阶段行业包只作为第二阶段目标,不作为第一阶段主路径。 + +### 8.3 不做“什么都能干”的单一大助手 + +不再强化一个泛化聊天助手来承接所有任务。 + +当前阶段要坚持: + +**多个职责明确的数字员工 > 一个无限泛化的大助手。** + +--- + +## 9. 三阶段路线图 + +### 9.1 阶段一:通用数字员工 1.0 + +**目标:** 做出可卖、可演示、可复制的标准版产品。 + +**核心建设项:** + +- 4 个通用数字员工 +- 专员市场 +- 专员工作区 +- 任务/交付/审核闭环 +- 模板中心 +- 知识引用与可追溯 +- 部门/岗位/知识空间绑定 + +**阶段结果:** + +平台能够对外统一表达为: + +> 一套可安装、可审核、可追溯的企业通用数字员工平台,先覆盖知识运营、培训考试、内容生成、任务推进四类共性场景。 + +### 9.2 阶段二:行业数字员工包 1.0 + +**目标:** 在通用底座之上,生长行业包,而不是重做底座。 + +优先建议的行业方向: + +- 销售赋能 +- 合规审查 +- 培训认证 + +这些方向都与当前知识底座和培训底座天然接近,落地成本相对最低。 + +### 9.3 阶段三:企业半定制能力 + +**目标:** 在不破坏标准产品的前提下,开放企业可配置能力。 + +建议逐步开放: + +- 自定义模板 +- 自定义知识空间绑定 +- 自定义专员副本 +- 自定义连接器绑定 +- 有边界的工作流配置 + +不建议一开始就完全开放到底层编排。 + +--- + +## 10. 对现有前后端的指导意义 + +### 10.1 前端 + +后续前端主轴应继续从「模块中心」转向「数字员工中心」: + +- 首页/工作台强调任务与数字员工 +- 市场页强调专员包安装与分类 +- 工坊页强调装配与配置,不强调无限自由编排 +- 知识库页退到数字员工底座与证据支撑角色 + +### 10.2 后端 + +后续后端优先保障以下统一模型: + +- specialist 的标准定义 +- worker_task 的统一状态机 +- worker_artifact 的统一交付模型 +- knowledge_space 的强边界约束 +- 模板中心与专员配置关系 + +### 10.3 商业表达 + +后续对外讲述口径建议统一为: + +> 我们不是先做一个无限自由的 AI 工作台,而是先做一组企业拿来就能用、可审核、可追溯、可升级的数字员工产品包。 + +这比「什么都能做」更容易被企业理解,也更容易成交。 + +--- + +## 11. 最终结论 + +当前项目的正确路线不是: + +**先追 WorkBuddy 的自由度,再想办法收回来。** + +而应该是: + +**先把数字员工做成标准化产品包,再在这个底座上逐步开放行业包和企业定制。** + +因此,当前阶段的产品主线正式定为: + +> **先通用,后定制;先产品包,后自由平台。** + +这是后续所有页面、模型、任务系统、交付系统、模板系统的上位约束。 diff --git a/eai_ap_app/.gitignore b/eai_ap_app/.gitignore index 54e71f5..981e5ea 100644 --- a/eai_ap_app/.gitignore +++ b/eai_ap_app/.gitignore @@ -8,6 +8,10 @@ backend-go/data/ frontend/node_modules/ frontend/dist/ +# 本地知识服务 +knowledge_service/data/ +knowledge_service/__pycache__/ + # 环境变量 .env backend-go/.env diff --git a/eai_ap_app/PROJECT_STATE.md b/eai_ap_app/PROJECT_STATE.md index 50e1bf8..0589e8f 100644 --- a/eai_ap_app/PROJECT_STATE.md +++ b/eai_ap_app/PROJECT_STATE.md @@ -1,7 +1,7 @@ # 项目状态总览(PROJECT_STATE) > 单一事实源:新对话默认先读此文件恢复上下文;每次关键决策后必须更新。 -> 最后更新:2026-08-16 +> 最后更新:2026-08-24 ## 1. 项目是什么 @@ -35,7 +35,8 @@ 社交社区/讨论区**定死不做** ### 待开始 -- ⬜ (暂无待办)前端/后端能力已基本闭环;后续为可选打磨项 +- ⬜ 按 `docs/01_System_Overall/SY19_Universal_Digital_Worker_Product_Planning.md` + 将当前项目从“已有专员雏形”进一步收口为“通用数字员工平台 V1.0” ## 3. 已拍板决策 @@ -60,6 +61,7 @@ | D17 | 错题本(学员自助,非学情分析,自测/正式均写)+ 简答题 LLM 评分(`essay` 题型,复用 title_gen 路由,`essay_grade` 不扣点) | 已定(P2 落地) | | D18 | 突破原「极致极简/杜绝学情分析」边界:新增学员成长体系 = 学习积分+排行榜(游戏化)+ 考试证书(认证)+ 学习档案/能力雷达(学情,学员自助视角);积分规则集中常量可调 | 已定(V1.6 落地) | | D19 | 部门组织架构(department 字典表,用户以 department 字符串归属、改名同步)+ 按部门学情统计;站内消息通知(考试发布/通过发证/设岗三事件);**社交社区/讨论区定死不做** | 已定(V1.7 落地) | +| D20 | 当前阶段产品路线收口为「先通用、后定制」:先做标准化通用数字员工产品包,不直接追 WorkBuddy 的高自由度路线;第一批优先沉淀知识运营、培训考试、内容生成、任务推进 4 个通用数字员工,后续再长行业包与企业半定制 | 已定(见 `docs/01_System_Overall/SY19_Universal_Digital_Worker_Product_Planning.md`) | ## 4. 文档索引 @@ -102,4 +104,4 @@ pj0231-eaisalestraining/ # 工作区根(仓库根) ├── CLAUDE.md ├── PROJECT_STATE.md └── .gitignore -``` \ No newline at end of file +``` diff --git a/eai_ap_app/backend-go/internal/ai/knowledge_service_client.go b/eai_ap_app/backend-go/internal/ai/knowledge_service_client.go new file mode 100644 index 0000000..04abf9b --- /dev/null +++ b/eai_ap_app/backend-go/internal/ai/knowledge_service_client.go @@ -0,0 +1,149 @@ +package ai + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "eaisalestrain/backend/internal/config" +) + +type KnowledgeClassifyResult struct { + Intent string `json:"intent"` + Score float64 `json:"score"` + Reason string `json:"reason"` +} + +type KnowledgeSearchItem struct { + ID uint `json:"id"` + Title string `json:"title"` + Snippet string `json:"snippet"` + Content string `json:"content"` + ChunkIndex int `json:"chunk_index"` + SourceType string `json:"source_type"` + SourceID string `json:"source_id"` + KnowledgeSpaceKey string `json:"knowledge_space_key"` + KnowledgeSpaceName string `json:"knowledge_space_name"` + Score float64 `json:"score"` +} + +type KnowledgeIndexItem struct { + ID uint `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + ChunkIndex int `json:"chunk_index"` + SourceType string `json:"source_type"` + SourceID string `json:"source_id"` + KnowledgeSpaceKey string `json:"knowledge_space_key"` + KnowledgeSpaceName string `json:"knowledge_space_name"` +} + +func KnowledgeServiceEnabled(cfg *config.Config) bool { + return cfg != nil && strings.TrimSpace(cfg.KnowledgeServiceURL) != "" +} + +func KnowledgeClassify(cfg *config.Config, query string) (*KnowledgeClassifyResult, error) { + var resp struct { + Data KnowledgeClassifyResult `json:"data"` + } + if err := postKnowledgeService(cfg, "/classify", map[string]any{ + "query": query, + }, &resp); err != nil { + return nil, err + } + return &resp.Data, nil +} + +func KnowledgeSearch(cfg *config.Config, query, spaceKey string, topK int) ([]KnowledgeSearchItem, error) { + var resp struct { + Data struct { + Items []KnowledgeSearchItem `json:"items"` + } `json:"data"` + } + if err := postKnowledgeService(cfg, "/search", map[string]any{ + "query": query, + "space_key": spaceKey, + "top_k": topK, + }, &resp); err != nil { + return nil, err + } + return resp.Data.Items, nil +} + +func KnowledgeRebuildIndex(cfg *config.Config, items []KnowledgeIndexItem) error { + var resp map[string]any + return postKnowledgeService(cfg, "/index/rebuild", map[string]any{ + "index_dir": cfg.KnowledgeIndexDir, + "items": items, + }, &resp) +} + +func postKnowledgeService(cfg *config.Config, path string, payload any, out any) error { + if !KnowledgeServiceEnabled(cfg) { + return fmt.Errorf("knowledge service disabled") + } + b, err := json.Marshal(payload) + if err != nil { + return err + } + url := strings.TrimRight(cfg.KnowledgeServiceURL, "/") + path + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + // #region debug-point D:knowledge-service-request + if payload, err := json.Marshal(map[string]any{ + "sessionId": "knowledge-chat-401", + "runId": "pre-fix", + "hypothesisId": "D", + "location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:request", + "msg": "[DEBUG] knowledge service request", + "data": map[string]any{ + "url": url, + "path": path, + }, + "ts": time.Now().UnixMilli(), + }); err == nil { + go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload))) + } + // #endregion + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + // #region debug-point D:knowledge-service-non200 + if payload, err := json.Marshal(map[string]any{ + "sessionId": "knowledge-chat-401", + "runId": "pre-fix", + "hypothesisId": "D", + "location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:non200", + "msg": "[DEBUG] knowledge service non-200", + "data": map[string]any{ + "url": url, + "status": resp.StatusCode, + "bodyPreview": truncate(string(body), 240), + }, + "ts": time.Now().UnixMilli(), + }); err == nil { + go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload))) + } + // #endregion + return fmt.Errorf("knowledge service %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + if out == nil || len(body) == 0 { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("knowledge service parse failed: %w", err) + } + return nil +} diff --git a/eai_ap_app/backend-go/internal/ai/llm.go b/eai_ap_app/backend-go/internal/ai/llm.go index 1d6a3f1..3f37534 100644 --- a/eai_ap_app/backend-go/internal/ai/llm.go +++ b/eai_ap_app/backend-go/internal/ai/llm.go @@ -44,12 +44,12 @@ type ChatResult struct { // Client 低层 HTTP 客户端(基于 RouteConfig) type Client struct { - baseURL string - apiKey string - model string - maxTokens int + baseURL string + apiKey string + model string + maxTokens int temperature float64 - hc *http.Client + hc *http.Client } // NewClient 从 RouteConfig 创建客户端 @@ -100,7 +100,45 @@ func (c *Client) post(path string, body any) (*http.Response, error) { for k, v := range c.headers() { req.Header.Set(k, v) } - return c.hc.Do(req) + // #region debug-point C:llm-post + if payload, err := json.Marshal(map[string]any{ + "sessionId": "knowledge-chat-401", + "runId": "pre-fix", + "hypothesisId": "C", + "location": "backend-go/internal/ai/llm.go:Client.post:request", + "msg": "[DEBUG] llm client request", + "data": map[string]any{ + "url": c.url(path), + "model": c.model, + "hasAPIKey": c.apiKey != "", + "authHeader": req.Header.Get("Authorization") != "", + }, + "ts": time.Now().UnixMilli(), + }); err == nil { + go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload))) + } + // #endregion + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + // #region debug-point C:llm-response + if payload, err := json.Marshal(map[string]any{ + "sessionId": "knowledge-chat-401", + "runId": "pre-fix", + "hypothesisId": "C", + "location": "backend-go/internal/ai/llm.go:Client.post:response", + "msg": "[DEBUG] llm client response", + "data": map[string]any{ + "url": c.url(path), + "status": resp.StatusCode, + }, + "ts": time.Now().UnixMilli(), + }); err == nil { + go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload))) + } + // #endregion + return resp, nil } // ────────────────────────────────────────────── @@ -222,6 +260,24 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error if resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) + // #region debug-point C:llm-non200 + if payload, err := json.Marshal(map[string]any{ + "sessionId": "knowledge-chat-401", + "runId": "pre-fix", + "hypothesisId": "C", + "location": "backend-go/internal/ai/llm.go:GenerateStream:non200", + "msg": "[DEBUG] llm stream non-200", + "data": map[string]any{ + "status": resp.StatusCode, + "bodyPreview": truncate(string(data), 240), + "model": c.model, + "baseURL": c.baseURL, + }, + "ts": time.Now().UnixMilli(), + }); err == nil { + go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload))) + } + // #endregion return fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200)) } @@ -335,6 +391,41 @@ func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) ( return nil, nil, fmt.Errorf("所有路由均失败: %w", lastErr) } +// GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由 +func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) { + chain := []*config.RouteConfig{primary} + if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 { + chain = append(chain, fallbacks...) + } + + var lastErr error + for _, route := range chain { + if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" { + lastErr = fmt.Errorf("[%s] 未配置 API Key", route.RouteID) + continue + } + client := NewClient(route) + if err := client.GenerateStream(messages, onChunk); err == nil { + return route, nil + } else { + lastErr = fmt.Errorf("[%s] %w", route.RouteID, err) + } + } + return nil, fmt.Errorf("所有路由均失败: %w", lastErr) +} + +func requiresAPIKey(route *config.RouteConfig) bool { + if route == nil { + return false + } + baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL)) + if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") { + return true + } + provider := strings.ToLower(strings.TrimSpace(route.Provider)) + return provider == "openrouter" || provider == "openai" +} + // ────────────────────────────────────────────── // 配置解析(兼容旧接口) // ────────────────────────────────────────────── @@ -375,11 +466,11 @@ func ResolveLLM(cfg *config.Config) (LLMConfig, bool) { } c := LLMConfig{ - BaseURL: baseURL, - APIKey: apiKey, - Model: modelName, - EmbedModel: embedModel, - MaxTokens: 2048, + BaseURL: baseURL, + APIKey: apiKey, + Model: modelName, + EmbedModel: embedModel, + MaxTokens: 2048, Temperature: 0.7, } if c.BaseURL == "" || c.Model == "" { @@ -394,4 +485,4 @@ func truncate(s string, n int) string { return s } return string(r[:n]) + "..." -} \ No newline at end of file +} diff --git a/eai_ap_app/backend-go/internal/api/ai_chat.go b/eai_ap_app/backend-go/internal/api/ai_chat.go index b75e0dd..a136292 100644 --- a/eai_ap_app/backend-go/internal/api/ai_chat.go +++ b/eai_ap_app/backend-go/internal/api/ai_chat.go @@ -44,22 +44,50 @@ func ChatMessage(c *gin.Context) { web.Fail(c, web.NewBadRequest("message 必填")) return } - if !checkBalance(c, user, ai.CapabilityAIChat) { - return + traceID := fmt.Sprintf("chat-%d", time.Now().UnixNano()) + // #region debug-point B:chat-entry + if payload, err := json.Marshal(gin.H{ + "sessionId": "knowledge-chat-401", + "runId": "pre-fix", + "hypothesisId": "B", + "location": "backend-go/internal/api/ai_chat.go:ChatMessage:entry", + "traceId": traceID, + "msg": "[DEBUG] chat message received", + "data": gin.H{ + "userId": user.ID, + "messagePreview": []rune(strings.TrimSpace(req.Message)), + "spaceKey": req.Context["knowledge_space_key"], + "historySize": len(req.History), + }, + "ts": time.Now().UnixMilli(), + }); err == nil { + go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload))) } - - route, err := config.GetRoute("path_coach") + // #endregion + plan, err := buildKnowledgeAnswerPlan(req.Message, req.Context, req.History) if err != nil { - web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error())) + web.Fail(c, web.NewBadRequest("知识问答链路执行失败")) return } - - knowledge := ai.Retrieve(Cfg, req.Message, 5) - messages := []ai.Message{{Role: "system", Content: buildSystemPrompt(req.Context, knowledge)}} - messages = append(messages, req.History...) - messages = append(messages, ai.Message{Role: "user", Content: req.Message}) - - client := ai.NewClient(route) + // #region debug-point B:plan-built + if payload, err := json.Marshal(gin.H{ + "sessionId": "knowledge-chat-401", + "runId": "pre-fix", + "hypothesisId": "B", + "location": "backend-go/internal/api/ai_chat.go:ChatMessage:plan", + "traceId": traceID, + "msg": "[DEBUG] knowledge plan built", + "data": gin.H{ + "intent": plan.Intent, + "layer": plan.Layer, + "citations": len(plan.Citations), + "llmMessageSize": len(plan.LLMMessages), + }, + "ts": time.Now().UnixMilli(), + }); err == nil { + go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload))) + } + // #endregion c.Writer.Header().Set("Content-Type", "text/event-stream") c.Writer.Header().Set("Cache-Control", "no-cache") @@ -78,11 +106,75 @@ func ChatMessage(c *gin.Context) { flusher.Flush() } + writeEvent(gin.H{ + "type": "meta", + "intent": plan.Intent, + "layer": plan.Layer, + "citations": plan.Citations, + }) + + if len(plan.LLMMessages) == 0 { + writeEvent(gin.H{"type": "text", "content": plan.Answer}) + writeEvent(gin.H{"type": "done"}) + return + } + + if !checkBalance(c, user, ai.CapabilityAIChat) { + writeEvent(gin.H{"type": "error", "message": "AI 点数不足,请联系管理员充值"}) + return + } + route, err := config.GetRoute("path_coach") + if err != nil { + writeEvent(gin.H{"type": "error", "message": "LLM 路由未配置: " + err.Error()}) + return + } + // #region debug-point B:llm-route + if payload, err := json.Marshal(gin.H{ + "sessionId": "knowledge-chat-401", + "runId": "pre-fix", + "hypothesisId": "B", + "location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-route", + "traceId": traceID, + "msg": "[DEBUG] llm route selected", + "data": gin.H{ + "routeId": route.RouteID, + "provider": route.Provider, + "baseURL": route.BaseURL, + "model": route.Model, + }, + "ts": time.Now().UnixMilli(), + }); err == nil { + go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload))) + } + // #endregion start := time.Now() - if err := client.GenerateStream(messages, func(chunk string) { + usedRoute, err := ai.GenerateStreamWithFallback(route, plan.LLMMessages, func(chunk string) { writeEvent(gin.H{"type": "text", "content": chunk}) - }); err != nil { - writeEvent(gin.H{"type": "error", "message": err.Error()}) + }) + if err != nil { + // #region debug-point B:llm-error + if payload, marshalErr := json.Marshal(gin.H{ + "sessionId": "knowledge-chat-401", + "runId": "pre-fix", + "hypothesisId": "B", + "location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-error", + "traceId": traceID, + "msg": "[DEBUG] llm stream returned error", + "data": gin.H{ + "error": err.Error(), + }, + "ts": time.Now().UnixMilli(), + }); marshalErr == nil { + go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload))) + } + // #endregion + message := err.Error() + if strings.Contains(message, "未配置 API Key") || strings.Contains(message, "No cookie auth credentials found") { + message = "当前知识库已进入 LLM 兜底层,但系统还没有配置可用的 LLM 服务。请管理员配置 OpenRouter API Key,或启动本地 Ollama 后再重试。" + } else if strings.Contains(message, "所有路由均失败") || strings.Contains(message, "LLM 服务不可达") { + message = "当前知识库已进入 LLM 兜底层,但本机没有可用的模型服务正在运行。请先启动本地 Ollama,或补充可用的远端模型配置。" + } + writeEvent(gin.H{"type": "error", "message": message}) ai.LogCall(ai.LogEntry{ UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider, RouteID: route.RouteID, Model: route.Model, Success: false, @@ -91,9 +183,13 @@ func ChatMessage(c *gin.Context) { return } writeEvent(gin.H{"type": "done"}) + finalRoute := route + if usedRoute != nil { + finalRoute = usedRoute + } ai.LogCall(ai.LogEntry{ - UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider, - RouteID: route.RouteID, Model: route.Model, Success: true, + UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: finalRoute.Provider, + RouteID: finalRoute.RouteID, Model: finalRoute.Model, Success: true, LatencyMs: int(time.Since(start).Milliseconds()), }) } @@ -137,15 +233,15 @@ var quickActionTask = map[string]struct { }{ "commission": { query: "产品佣金 佣金规则 回款 结算 公开课奖励", - task: "请基于下方知识库,汇总相关产品的佣金比例、结算规则与奖励规则;知识库未覆盖的部分明确说明,不得臆测。", + task: "请基于下方知识库,输出“佣金/规则清单”。格式固定为:一、适用范围;二、核心规则;三、注意事项;四、建议追问。每部分用短句或条目表达,不得编造知识库未覆盖的内容。", }, "compare": { query: "产品对比 定位 收费 适用场景", - task: "请基于下方知识库,对比相关产品的定位、收费与适用场景,突出差异。", + task: "请基于下方知识库,输出“产品对比结果”。格式固定为:一、对比对象;二、共同点;三、核心差异;四、适用客户;五、销售建议。不要写成普通摘要,要写成结构化对比结果。", }, "scenario": { query: "销售话术 销售流程 异议处理 情景演练", - task: "请基于下方知识库,扮演销售进行客户情景演练:推介相关产品或课程,并演示异议处理话术。", + task: "请基于下方知识库,输出一段“客户情景演练脚本”。格式固定为:客户、销售、客户、销售 四轮对话;最后补一行“本轮话术重点”。语言要像真实销售沟通,不要写成说明文。", }, } @@ -167,17 +263,39 @@ func QuickAction(c *gin.Context) { if !checkBalance(c, user, ai.CapabilityTextGen) { return } - route, err := config.GetRoute("title_gen") - if err != nil { - web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error())) - return - } task, ok := quickActionTask[req.ActionID] if !ok { web.Fail(c, web.NewBadRequest("未知快捷动作")) return } - knowledge := ai.Retrieve(Cfg, task.query, 5) + spaceKey := extractKnowledgeSpaceKey(req.Params) + citations := retrieveKnowledgeCitations(task.query, spaceKey, 5) + fallbackResult := buildQuickActionFallback(req.ActionID, citations) + if len(citations) == 0 { + web.OK(c, gin.H{ + "result": fallbackResult, + "citations": citations, + "layer": layerVector, + "intent": intentDocument, + }) + return + } + + knowledge := make([]string, 0, len(citations)) + for _, item := range citations { + knowledge = append(knowledge, item.Content) + } + route, err := config.GetRoute("title_gen") + if err != nil { + web.OK(c, gin.H{ + "result": fallbackResult, + "citations": citations, + "layer": layerVector, + "intent": intentDocument, + "fallback": true, + }) + return + } systemPrompt := buildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task messages := []ai.Message{ {Role: "system", Content: systemPrompt}, @@ -192,7 +310,13 @@ func QuickAction(c *gin.Context) { RouteID: route.RouteID, Model: route.Model, Success: false, ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()), }) - web.Fail(c, web.NewLLMError("LLM 调用失败:"+err.Error())) + web.OK(c, gin.H{ + "result": fallbackResult, + "citations": citations, + "layer": layerVector, + "intent": intentDocument, + "fallback": true, + }) return } ai.LogCall(ai.LogEntry{ @@ -201,9 +325,99 @@ func QuickAction(c *gin.Context) { TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens, LatencyMs: int(time.Since(start).Milliseconds()), }) - out := gin.H{"result": result.Content} + out := gin.H{ + "result": result.Content, + "citations": citations, + "layer": layerLLM, + "intent": intentDocument, + } if req.ActionID == "scenario" { out["mode"] = "scenario" } web.OK(c, out) } + +func buildQuickActionFallback(actionID string, citations []knowledgeCitation) string { + if len(citations) == 0 { + return "当前知识库里还没有找到足够资料,暂时无法生成这个快捷结果。建议先切换到更合适的知识库,或继续补充 FAQ 与文档后再试。" + } + switch actionID { + case "commission": + lines := []string{ + "佣金/规则清单", + "", + "一、适用范围", + "适用于当前知识库里已收录的产品佣金、谈判规则与相关业务口径。", + "", + "二、核心规则", + } + for idx, item := range citations { + if idx >= 3 { + break + } + lines = append(lines, fmt.Sprintf("%d. %s", idx+1, strings.TrimSpace(item.Snippet))) + } + lines = append(lines, + "", + "三、注意事项", + "- 涉及价格政策、合同条款、付款条件等敏感信息时,应按制度要求先审阅后发布。", + "- 知识库未明确写到的比例、例外情况或特殊口径,不要直接对外承诺。", + "", + "四、建议追问", + "- 帮我按产品分别列出佣金规则", + "- 哪些规则属于可谈判范围", + ) + return strings.Join(lines, "\n") + case "compare": + lines := []string{ + "产品对比结果", + "", + "一、对比对象", + "- 当前知识库可优先对比公开课、训练营、陪跑型服务等方案。", + "", + "二、共同点", + "- 都服务于客户学习提升、业务落地或能力建设目标。", + "", + "三、核心差异", + } + for idx, item := range citations { + if idx >= 3 { + break + } + lines = append(lines, fmt.Sprintf("%d. %s", idx+1, strings.TrimSpace(item.Snippet))) + } + lines = append(lines, + "", + "四、适用客户", + "- 公开课更适合快速普及认知;训练营和陪跑型方案更适合需要结果产出和过程辅导的客户。", + "", + "五、销售建议", + "- 先判断客户是“只想了解”还是“希望真正落地”,再决定推荐公开课还是训练营/陪跑服务。", + ) + return strings.Join(lines, "\n") + case "scenario": + first := strings.TrimSpace(citations[0].Snippet) + second := first + if len(citations) > 1 { + second = strings.TrimSpace(citations[1].Snippet) + } + if first == "" { + first = strings.TrimSpace(citations[0].Content) + } + if second == "" { + second = first + } + return strings.Join([]string{ + "客户情景演练脚本", + "", + "客户:我们之前也上过一些课,但团队学完还是落不了地,你们这边有什么更适合的方案吗?", + "销售:有的。根据我们当前知识库里的资料," + first, + "客户:那如果我们更关注执行过程,担心中途推进不动,怎么办?", + "销售:" + second, + "", + "本轮话术重点:先确认客户是“只想了解”还是“希望真正落地”,再把课程、作业、点评和陪跑闭环讲清楚。", + }, "\n") + default: + return buildVectorDirectAnswer(citations) + } +} diff --git a/eai_ap_app/backend-go/internal/api/knowledge.go b/eai_ap_app/backend-go/internal/api/knowledge.go index 3d9bb5d..9d64c32 100644 --- a/eai_ap_app/backend-go/internal/api/knowledge.go +++ b/eai_ap_app/backend-go/internal/api/knowledge.go @@ -50,18 +50,19 @@ func KnowledgeScan(c *gin.Context) { continue } src := model.KnowledgeSource{ - Title: parseTitle(string(data)), - FilePath: e.Name(), - Category: fm["category"], - Domain: orDefault(fm["domain"], "product"), - SourceVersion: fm["version"], - AuditStatus: "pending", + Title: parseTitle(string(data)), + FilePath: e.Name(), + Category: fm["category"], + Domain: orDefault(fm["domain"], "product"), + SourceVersion: fm["version"], + AuditStatus: "pending", + KnowledgeSpaceKey: ensureKnowledgeSpaceKeyOrDefault(orDefault(fm["knowledge_space_key"], inferKnowledgeSpaceKey(parseTitle(string(data)), fm["domain"], fm["category"]+" "+e.Name()))), } if err := store.DB.Create(&src).Error; err != nil { results = append(results, gin.H{"file_path": e.Name(), "status": "error", "title": src.Title}) continue } - results = append(results, gin.H{"file_path": e.Name(), "status": "created", "title": src.Title}) + results = append(results, gin.H{"file_path": e.Name(), "status": "created", "title": src.Title, "knowledge_space_key": src.KnowledgeSpaceKey}) } web.OK(c, gin.H{"results": results}) } @@ -72,6 +73,9 @@ func KnowledgeAuditList(c *gin.Context) { if s := c.Query("status"); s != "" { q = q.Where("audit_status = ?", s) } + if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" { + q = q.Where("knowledge_space_key = ?", key) + } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) if page < 1 { @@ -129,6 +133,7 @@ func KnowledgeAudit(c *gin.Context) { web.Fail(c, web.NewBadRequest("审批失败")) return } + triggerKnowledgeIndexRebuild() web.OK(c, gin.H{ "status": "approved", "source_id": src.ID, "products": counts[0], "chunks": counts[1], "questions": counts[2], @@ -164,9 +169,10 @@ func KnowledgeStatus(c *gin.Context) { return } web.OK(c, gin.H{ - "audit_status": src.AuditStatus, - "ingested": src.Ingested, - "reject_reason": src.RejectReason, + "audit_status": src.AuditStatus, + "ingested": src.Ingested, + "reject_reason": src.RejectReason, + "knowledge_space_key": resolveKnowledgeSourceSpaceKey(src), }) } @@ -349,6 +355,7 @@ func ingestSource(src *model.KnowledgeSource) ([3]int, error) { KnowledgeSourceID: &src.ID, SourceType: "md", SourceID: strconv.FormatUint(uint64(src.ID), 10), + KnowledgeSpaceKey: resolveKnowledgeSourceSpaceKey(*src), ChunkIndex: i, Content: text, }) diff --git a/eai_ap_app/backend-go/internal/api/knowledge_faq.go b/eai_ap_app/backend-go/internal/api/knowledge_faq.go new file mode 100644 index 0000000..e160b3b --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/knowledge_faq.go @@ -0,0 +1,204 @@ +package api + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "eaisalestrain/backend/internal/model" + "eaisalestrain/backend/internal/store" + "eaisalestrain/backend/internal/web" +) + +// ListKnowledgeFAQs GET /api/knowledge/faqs?knowledge_space_key=&status=&keyword=&page=&size= +func ListKnowledgeFAQs(c *gin.Context) { + q := store.DB.Model(&model.KnowledgeFAQ{}) + if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" { + q = q.Where("knowledge_space_key = ?", key) + } + if status := strings.TrimSpace(c.Query("status")); status != "" { + q = q.Where("status = ?", status) + } + if keyword := strings.TrimSpace(c.Query("keyword")); keyword != "" { + like := "%" + keyword + "%" + q = q.Where("question LIKE ? OR answer LIKE ? OR keywords LIKE ?", like, like, like) + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + var total int64 + q.Count(&total) + var items []model.KnowledgeFAQ + if err := q.Order("sort_order ASC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil { + web.Fail(c, web.NewBadRequest("加载 FAQ 失败")) + return + } + web.OK(c, gin.H{ + "total": total, + "items": buildFAQItems(items), + }) +} + +// CreateKnowledgeFAQ POST /api/knowledge/faqs +func CreateKnowledgeFAQ(c *gin.Context) { + var req struct { + KnowledgeSpaceKey string `json:"knowledge_space_key"` + Question string `json:"question"` + Answer string `json:"answer"` + SimilarQuestions []string `json:"similar_questions"` + Keywords []string `json:"keywords"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + question := strings.TrimSpace(req.Question) + answer := strings.TrimSpace(req.Answer) + if question == "" || answer == "" { + web.Fail(c, web.NewBadRequest("question 与 answer 必填")) + return + } + similarJSON, _ := json.Marshal(cleanStringList(req.SimilarQuestions)) + faq := model.KnowledgeFAQ{ + KnowledgeSpaceKey: ensureKnowledgeSpaceKeyOrDefault(req.KnowledgeSpaceKey), + Question: question, + Answer: answer, + SimilarQuestions: string(similarJSON), + Keywords: strings.Join(cleanStringList(req.Keywords), ","), + Status: orDefault(strings.TrimSpace(req.Status), "active"), + SortOrder: req.SortOrder, + } + if err := store.DB.Create(&faq).Error; err != nil { + web.Fail(c, web.NewBadRequest("创建 FAQ 失败")) + return + } + web.OK(c, gin.H{"item": buildFAQItem(faq)}) +} + +// UpdateKnowledgeFAQ PUT /api/knowledge/faqs/:id +func UpdateKnowledgeFAQ(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + var faq model.KnowledgeFAQ + if err := store.DB.First(&faq, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("FAQ 不存在")) + return + } + var req struct { + KnowledgeSpaceKey string `json:"knowledge_space_key"` + Question string `json:"question"` + Answer string `json:"answer"` + SimilarQuestions []string `json:"similar_questions"` + Keywords []string `json:"keywords"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + question := strings.TrimSpace(req.Question) + answer := strings.TrimSpace(req.Answer) + if question == "" || answer == "" { + web.Fail(c, web.NewBadRequest("question 与 answer 必填")) + return + } + similarJSON, _ := json.Marshal(cleanStringList(req.SimilarQuestions)) + faq.KnowledgeSpaceKey = ensureKnowledgeSpaceKeyOrDefault(req.KnowledgeSpaceKey) + faq.Question = question + faq.Answer = answer + faq.SimilarQuestions = string(similarJSON) + faq.Keywords = strings.Join(cleanStringList(req.Keywords), ",") + faq.Status = orDefault(strings.TrimSpace(req.Status), "active") + faq.SortOrder = req.SortOrder + if err := store.DB.Save(&faq).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新 FAQ 失败")) + return + } + web.OK(c, gin.H{"item": buildFAQItem(faq)}) +} + +// DeleteKnowledgeFAQ DELETE /api/knowledge/faqs/:id +func DeleteKnowledgeFAQ(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + var faq model.KnowledgeFAQ + if err := store.DB.First(&faq, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("FAQ 不存在")) + return + } + if err := store.DB.Delete(&faq).Error; err != nil { + web.Fail(c, web.NewBadRequest("删除 FAQ 失败")) + return + } + web.OK(c, gin.H{"deleted": true}) +} + +func buildFAQItems(items []model.KnowledgeFAQ) []gin.H { + out := make([]gin.H, 0, len(items)) + for _, item := range items { + out = append(out, buildFAQItem(item)) + } + return out +} + +func buildFAQItem(item model.KnowledgeFAQ) gin.H { + return gin.H{ + "id": item.ID, + "knowledge_space_key": item.KnowledgeSpaceKey, + "knowledge_space_name": getKnowledgeSpaceDisplayName(item.KnowledgeSpaceKey), + "question": item.Question, + "answer": item.Answer, + "similar_questions": parseJSONArray(item.SimilarQuestions), + "keywords": splitCSV(item.Keywords), + "status": item.Status, + "sort_order": item.SortOrder, + "hit_count": item.HitCount, + "created_at": item.CreatedAt, + "updated_at": item.UpdatedAt, + } +} + +func parseJSONArray(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return []string{} + } + var out []string + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return []string{} + } + return cleanStringList(out) +} + +func splitCSV(raw string) []string { + parts := strings.Split(raw, ",") + return cleanStringList(parts) +} + +func cleanStringList(items []string) []string { + out := make([]string, 0, len(items)) + seen := map[string]bool{} + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return out +} diff --git a/eai_ap_app/backend-go/internal/api/knowledge_index.go b/eai_ap_app/backend-go/internal/api/knowledge_index.go new file mode 100644 index 0000000..51af33c --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/knowledge_index.go @@ -0,0 +1,91 @@ +package api + +import ( + "log" + "sync" + + "github.com/gin-gonic/gin" + + "eaisalestrain/backend/internal/ai" + "eaisalestrain/backend/internal/model" + "eaisalestrain/backend/internal/store" + "eaisalestrain/backend/internal/web" +) + +var knowledgeIndexRebuildLock sync.Mutex + +// RebuildKnowledgeIndex POST /api/knowledge/index/rebuild +func RebuildKnowledgeIndex(c *gin.Context) { + count, err := rebuildKnowledgeIndexNow() + if err != nil { + web.Fail(c, web.NewBadRequest("重建向量索引失败: "+err.Error())) + return + } + web.OK(c, gin.H{ + "rebuild": true, + "chunk_count": count, + "service_enabled": ai.KnowledgeServiceEnabled(Cfg), + }) +} + +func triggerKnowledgeIndexRebuild() { + if !ai.KnowledgeServiceEnabled(Cfg) { + return + } + go func() { + if count, err := rebuildKnowledgeIndexNow(); err != nil { + log.Printf("[知识索引] 重建失败: %v", err) + } else { + log.Printf("[知识索引] 重建完成,chunks=%d", count) + } + }() +} + +func rebuildKnowledgeIndexNow() (int, error) { + knowledgeIndexRebuildLock.Lock() + defer knowledgeIndexRebuildLock.Unlock() + + items := buildKnowledgeIndexItems() + if err := ai.KnowledgeRebuildIndex(Cfg, items); err != nil { + return 0, err + } + return len(items), nil +} + +func buildKnowledgeIndexItems() []ai.KnowledgeIndexItem { + var chunks []model.KnowledgeChunk + store.DB.Order("id ASC").Find(&chunks) + + var mediaFiles []model.MediaFile + store.DB.Where("status = ?", "approved").Find(&mediaFiles) + mediaMap := make(map[uint]model.MediaFile, len(mediaFiles)) + for _, item := range mediaFiles { + mediaMap[item.ID] = item + } + + var sources []model.KnowledgeSource + store.DB.Where("audit_status = ?", "approved").Find(&sources) + sourceMap := make(map[uint]model.KnowledgeSource, len(sources)) + for _, item := range sources { + sourceMap[item.ID] = item + } + + items := make([]ai.KnowledgeIndexItem, 0, len(chunks)) + for _, chunk := range chunks { + title, spaceKey, ok := resolveChunkMeta(chunk, mediaMap, sourceMap) + if !ok || chunk.Content == "" { + continue + } + items = append(items, ai.KnowledgeIndexItem{ + ID: chunk.ID, + Title: title, + Content: chunk.Content, + ChunkIndex: chunk.ChunkIndex, + SourceType: chunk.SourceType, + SourceID: chunk.SourceID, + KnowledgeSpaceKey: spaceKey, + KnowledgeSpaceName: getKnowledgeSpaceDisplayName(spaceKey), + }) + } + return items +} diff --git a/eai_ap_app/backend-go/internal/api/knowledge_pipeline.go b/eai_ap_app/backend-go/internal/api/knowledge_pipeline.go new file mode 100644 index 0000000..790d186 --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/knowledge_pipeline.go @@ -0,0 +1,525 @@ +package api + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strings" + + "gorm.io/gorm" + + "eaisalestrain/backend/internal/ai" + "eaisalestrain/backend/internal/config" + "eaisalestrain/backend/internal/model" + "eaisalestrain/backend/internal/store" +) + +const ( + intentInvalid = "invalid" + intentSmallTalk = "smalltalk" + intentOutOfScope = "out_of_scope" + intentFAQ = "faq" + intentDocument = "document" + + layerClassifier = "bert_classifier" + layerFAQ = "faq" + layerVector = "vector" + layerLLM = "llm_fallback" +) + +type knowledgeCitation struct { + ID uint `json:"id"` + Title string `json:"title"` + Snippet string `json:"snippet"` + Content string `json:"content"` + ChunkIndex int `json:"chunk_index"` + SourceType string `json:"source_type"` + SourceID string `json:"source_id"` + KnowledgeSpaceKey string `json:"knowledge_space_key"` + KnowledgeSpaceName string `json:"knowledge_space_name"` + Score float64 `json:"score"` +} + +type knowledgeAnswerPlan struct { + Intent string + Layer string + Answer string + Citations []knowledgeCitation + LLMMessages []ai.Message +} + +func buildKnowledgeAnswerPlan(query string, ctx map[string]any, history []ai.Message) (*knowledgeAnswerPlan, error) { + intent, interceptAnswer := classifyKnowledgeIntent(query) + if interceptAnswer != "" { + return &knowledgeAnswerPlan{ + Intent: intent, + Layer: layerClassifier, + Answer: interceptAnswer, + Citations: []knowledgeCitation{}, + }, nil + } + + spaceKey := extractKnowledgeSpaceKey(ctx) + if intent == intentFAQ { + if faq, ok := matchKnowledgeFAQ(query, spaceKey); ok { + return &knowledgeAnswerPlan{ + Intent: intent, + Layer: layerFAQ, + Answer: faq.Answer, + Citations: []knowledgeCitation{{ + ID: faq.ID, + Title: "FAQ 标准答案", + Snippet: faq.Question, + Content: faq.Answer, + SourceType: "faq", + SourceID: fmt.Sprintf("%d", faq.ID), + KnowledgeSpaceKey: faq.KnowledgeSpaceKey, + KnowledgeSpaceName: getKnowledgeSpaceDisplayName(faq.KnowledgeSpaceKey), + Score: 1, + }}, + }, nil + } + } + + citations := retrieveKnowledgeCitations(query, spaceKey, 5) + if canDirectAnswerFromVector(query, citations) { + return &knowledgeAnswerPlan{ + Intent: intent, + Layer: layerVector, + Answer: buildVectorDirectAnswer(citations), + Citations: citations, + }, nil + } + + messages := []ai.Message{{ + Role: "system", + Content: buildKnowledgePipelinePrompt(ctx, intent, citations), + }} + messages = append(messages, history...) + messages = append(messages, ai.Message{Role: "user", Content: query}) + return &knowledgeAnswerPlan{ + Intent: intent, + Layer: layerLLM, + Citations: citations, + LLMMessages: messages, + }, nil +} + +func classifyKnowledgeIntent(query string) (string, string) { + if ai.KnowledgeServiceEnabled(Cfg) { + if result, err := ai.KnowledgeClassify(Cfg, query); err == nil && result != nil { + switch result.Intent { + case intentSmallTalk: + return intentSmallTalk, buildSmallTalkAnswer(query) + case intentOutOfScope: + if result.Score >= 0.9 { + return intentOutOfScope, "当前知识库仅面向博昇 AI 实验室内部业务知识,不处理越域问题。" + } + case intentInvalid: + if result.Score >= 0.9 { + return intentInvalid, "请输入更明确的问题后再试。" + } + case intentFAQ, intentDocument: + if result.Score >= 0.6 { + return result.Intent, "" + } + } + } + } + return heuristicKnowledgeIntent(query) +} + +func heuristicKnowledgeIntent(query string) (string, string) { + trimmed := strings.TrimSpace(query) + if len([]rune(trimmed)) < 2 { + return intentInvalid, "请输入更明确的问题后再试。" + } + lower := strings.ToLower(trimmed) + smallTalkKeywords := []string{"讲个故事", "小故事", "笑话", "闲聊", "聊天", "夸夸我", "你是谁", "今天天气", "天气", "星座"} + for _, keyword := range smallTalkKeywords { + if strings.Contains(lower, keyword) { + return intentSmallTalk, buildSmallTalkAnswer(query) + } + } + outOfScopeKeywords := []string{"股票", "彩票", "电影", "明星", "旅游", "菜谱", "医学诊断", "法律咨询", "写诗", "翻译成英文"} + for _, keyword := range outOfScopeKeywords { + if strings.Contains(lower, keyword) { + return intentOutOfScope, "当前知识库仅面向博昇 AI 实验室内部业务知识,不处理越域问题。" + } + } + documentKeywords := []string{"总结", "梳理", "分析", "对比", "归纳", "提纲", "解读", "起草", "生成", "合同", "报告", "方案", "条款", "根据资料", "根据文档", "整理"} + for _, keyword := range documentKeywords { + if strings.Contains(lower, keyword) { + return intentDocument, "" + } + } + faqKeywords := []string{"如何", "怎么", "哪里", "在哪", "是否", "有没有", "可以", "支持", "密码", "登录", "佣金", "规则", "流程", "审批", "上传", "删除", "新建"} + for _, keyword := range faqKeywords { + if strings.Contains(lower, keyword) { + return intentFAQ, "" + } + } + if len([]rune(trimmed)) <= 18 { + return intentFAQ, "" + } + return intentDocument, "" +} + +func buildSmallTalkAnswer(query string) string { + lower := strings.ToLower(strings.TrimSpace(query)) + switch { + case strings.Contains(lower, "你是谁"): + return "你好,我是博昇 AI 实验室的知识库助手,负责基于企业知识库回答业务制度、产品资料、培训内容和规则流程相关问题。你可以直接提问,也可以先选择左侧知识库缩小检索范围。" + case strings.Contains(lower, "你能做什么"), strings.Contains(lower, "怎么工作"): + return "我可以帮你回答制度规则、产品资料、FAQ、培训文档相关问题,也可以基于知识库做摘要、梳理、对比和提纲生成。默认会全局检索全部知识库,你也可以在左侧切换到某个知识库后再提问。" + case strings.Contains(lower, "你好"), strings.Contains(lower, "hello"), strings.Contains(lower, "hi"): + return "你好,欢迎使用博昇 AI 实验室知识库助手。你可以直接问我产品资料、审批规则、培训内容、FAQ 或制度流程相关问题,我会按知识库内容为你检索和回答。" + default: + return "你好,我是知识库助手。当前更适合处理业务知识、制度规则、产品资料和培训文档相关问题;如果你愿意,我可以继续帮你查规则、找资料或整理内容。" + } +} + +func extractKnowledgeSpaceKey(ctx map[string]any) string { + if ctx == nil { + return "all" + } + if raw, ok := ctx["knowledge_space_key"]; ok { + key := sanitizeSpaceKey(fmt.Sprint(raw)) + if key == "" { + return "all" + } + if key == "all" { + return "all" + } + return ensureKnowledgeSpaceKeyOrDefault(key) + } + return "all" +} + +func matchKnowledgeFAQ(query, spaceKey string) (model.KnowledgeFAQ, bool) { + spaceKey = sanitizeSpaceKey(spaceKey) + q := store.DB.Where("status = ?", "active") + if spaceKey != "" && spaceKey != "general" && spaceKey != "all" { + q = q.Where("knowledge_space_key IN ?", []string{spaceKey, "general"}) + } + var faqs []model.KnowledgeFAQ + if err := q.Order("sort_order ASC, id DESC").Find(&faqs).Error; err != nil { + return model.KnowledgeFAQ{}, false + } + queryNorm := normalizeQuestion(query) + queryTerms := buildSearchTerms(query) + bestScore := 0 + var best model.KnowledgeFAQ + for _, faq := range faqs { + score := scoreFAQ(queryNorm, queryTerms, faq) + if score > bestScore { + bestScore = score + best = faq + } + } + if bestScore < 70 { + return model.KnowledgeFAQ{}, false + } + store.DB.Model(&model.KnowledgeFAQ{}).Where("id = ?", best.ID). + UpdateColumn("hit_count", gorm.Expr("hit_count + ?", 1)) + return best, true +} + +func scoreFAQ(queryNorm string, queryTerms []string, faq model.KnowledgeFAQ) int { + questionNorm := normalizeQuestion(faq.Question) + if queryNorm == questionNorm { + return 100 + } + best := 0 + if queryNorm != "" && (strings.Contains(queryNorm, questionNorm) || strings.Contains(questionNorm, queryNorm)) { + best = 88 + } + for _, alias := range parseJSONArray(faq.SimilarQuestions) { + aliasNorm := normalizeQuestion(alias) + if aliasNorm == "" { + continue + } + if queryNorm == aliasNorm { + return 96 + } + if strings.Contains(queryNorm, aliasNorm) || strings.Contains(aliasNorm, queryNorm) { + if best < 84 { + best = 84 + } + } + } + questionTerms := buildSearchTerms(faq.Question + " " + strings.Join(parseJSONArray(faq.SimilarQuestions), " ")) + overlap := countTermOverlap(queryTerms, questionTerms) + if overlap > 0 { + score := 50 + overlap*8 + if score > best { + best = score + } + } + keywordHits := countTermOverlap(queryTerms, splitCSV(faq.Keywords)) + if keywordHits > 0 { + score := 58 + keywordHits*10 + if score > best { + best = score + } + } + return best +} + +func normalizeQuestion(input string) string { + input = strings.ToLower(strings.TrimSpace(input)) + var b strings.Builder + for _, ch := range input { + switch ch { + case ' ', '\t', '\n', '\r', ',', ',', '。', '.', '?', '?', '!', '!', ':', ':', ';', ';', '、', '-', '_': + continue + default: + b.WriteRune(ch) + } + } + return b.String() +} + +func countTermOverlap(left, right []string) int { + if len(left) == 0 || len(right) == 0 { + return 0 + } + seen := map[string]bool{} + for _, item := range right { + seen[strings.ToLower(strings.TrimSpace(item))] = true + } + total := 0 + for _, item := range left { + if seen[strings.ToLower(strings.TrimSpace(item))] { + total++ + } + } + return total +} + +func retrieveKnowledgeCitations(query, spaceKey string, topK int) []knowledgeCitation { + if topK <= 0 { + topK = 5 + } + if ai.KnowledgeServiceEnabled(Cfg) { + if items, err := ai.KnowledgeSearch(Cfg, query, spaceKey, topK); err == nil { + out := make([]knowledgeCitation, 0, len(items)) + for _, item := range items { + out = append(out, knowledgeCitation{ + ID: item.ID, + Title: item.Title, + Snippet: item.Snippet, + Content: item.Content, + ChunkIndex: item.ChunkIndex, + SourceType: item.SourceType, + SourceID: item.SourceID, + KnowledgeSpaceKey: item.KnowledgeSpaceKey, + KnowledgeSpaceName: item.KnowledgeSpaceName, + Score: item.Score, + }) + } + return out + } + } + candidates := loadKnowledgeCandidates(spaceKey) + if len(candidates) == 0 { + return []knowledgeCitation{} + } + if route, err := config.GetRoute("embed_gen"); err == nil { + if items, ok := vectorRetrieveCitations(route, query, candidates, topK); ok { + return items + } + } + return keywordRetrieveCitations(query, candidates, topK) +} + +type knowledgeCandidate struct { + Chunk model.KnowledgeChunk + Title string + Space string +} + +type retrievalScore struct { + Index int + Score float64 +} + +func loadKnowledgeCandidates(spaceKey string) []knowledgeCandidate { + var chunks []model.KnowledgeChunk + store.DB.Order("id ASC").Find(&chunks) + mediaMap := map[uint]model.MediaFile{} + sourceMap := map[uint]model.KnowledgeSource{} + out := make([]knowledgeCandidate, 0, len(chunks)) + for _, chunk := range chunks { + title, resolvedSpace, ok := resolveChunkMeta(chunk, mediaMap, sourceMap) + if !ok { + continue + } + if !matchSpaceForKnowledgeQuery(spaceKey, resolvedSpace) { + continue + } + out = append(out, knowledgeCandidate{Chunk: chunk, Title: title, Space: resolvedSpace}) + } + return out +} + +func matchSpaceForKnowledgeQuery(selected, current string) bool { + selected = sanitizeSpaceKey(selected) + current = sanitizeSpaceKey(current) + if selected == "" || selected == "general" || selected == "all" { + return true + } + return current == selected || current == "general" +} + +func vectorRetrieveCitations(route *config.RouteConfig, query string, candidates []knowledgeCandidate, topK int) ([]knowledgeCitation, bool) { + client := ai.NewClient(route) + inputs := make([]string, 0, len(candidates)+1) + inputs = append(inputs, query) + for _, item := range candidates { + inputs = append(inputs, item.Chunk.Content) + } + vecs, err := client.Embed(inputs) + if err != nil || len(vecs) != len(inputs) { + return nil, false + } + qv := vecs[0] + items := make([]retrievalScore, 0, len(candidates)) + for idx := 1; idx < len(vecs); idx++ { + score := cosineSimilarity(qv, vecs[idx]) + if score <= 0 { + continue + } + items = append(items, retrievalScore{Index: idx - 1, Score: score}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Score > items[j].Score }) + return makeKnowledgeCitations(query, candidates, items, topK), true +} + +func keywordRetrieveCitations(query string, candidates []knowledgeCandidate, topK int) []knowledgeCitation { + terms := buildSearchTerms(query) + items := make([]retrievalScore, 0, len(candidates)) + for idx, item := range candidates { + score := float64(scoreChunkMatch(item.Chunk.Content, item.Title, query, terms)) / 20 + if score <= 0 { + continue + } + items = append(items, retrievalScore{Index: idx, Score: score}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Score > items[j].Score }) + return makeKnowledgeCitations(query, candidates, items, topK) +} + +func makeKnowledgeCitations(query string, candidates []knowledgeCandidate, items []retrievalScore, topK int) []knowledgeCitation { + seen := map[uint]bool{} + out := make([]knowledgeCitation, 0, topK) + for _, item := range items { + candidate := candidates[item.Index] + if seen[candidate.Chunk.ID] { + continue + } + seen[candidate.Chunk.ID] = true + out = append(out, knowledgeCitation{ + ID: candidate.Chunk.ID, + Title: candidate.Title, + Snippet: buildChunkSnippet(candidate.Chunk.Content, query), + Content: candidate.Chunk.Content, + ChunkIndex: candidate.Chunk.ChunkIndex, + SourceType: candidate.Chunk.SourceType, + SourceID: candidate.Chunk.SourceID, + KnowledgeSpaceKey: candidate.Space, + KnowledgeSpaceName: getKnowledgeSpaceDisplayName(candidate.Space), + Score: item.Score, + }) + if len(out) >= topK { + break + } + } + return out +} + +func canDirectAnswerFromVector(query string, citations []knowledgeCitation) bool { + if len(citations) == 0 { + return false + } + if citations[0].Score < 0.35 { + return false + } + return !needsLLMFallback(query, citations) +} + +func needsLLMFallback(query string, citations []knowledgeCitation) bool { + lower := strings.ToLower(strings.TrimSpace(query)) + keywords := []string{"总结", "梳理", "分析", "对比", "归纳", "起草", "生成", "提纲", "方案", "报告", "合同", "条款", "审阅", "写一份", "整理"} + for _, keyword := range keywords { + if strings.Contains(lower, keyword) { + return true + } + } + return len(citations) > 1 && len([]rune(query)) > 20 +} + +func buildVectorDirectAnswer(citations []knowledgeCitation) string { + if len(citations) == 0 { + return "未找到相关资料。" + } + lines := []string{"根据知识库命中的资料,先给你直接结果:"} + for idx, citation := range citations { + if idx >= 3 { + break + } + lines = append(lines, fmt.Sprintf("%d. %s", idx+1, strings.TrimSpace(citation.Snippet))) + } + lines = append(lines, "如需继续做归纳、对比或正式输出,可以继续追问。") + return strings.Join(lines, "\n") +} + +func buildKnowledgePipelinePrompt(ctx map[string]any, intent string, citations []knowledgeCitation) string { + ctxJSON, _ := json.Marshal(ctx) + var kb strings.Builder + for idx, citation := range citations { + if idx >= 5 { + break + } + kb.WriteString(fmt.Sprintf("[%d] 标题:%s\n空间:%s\n内容:%s\n\n", idx+1, citation.Title, citation.KnowledgeSpaceName, citation.Content)) + } + if kb.Len() == 0 { + kb.WriteString("未找到有效知识片段。") + } + return fmt.Sprintf(`你是博昇 AI 实验室知识库问答助手。 + +当前链路位置:LLM 兜底层 +前置结果: +1. Bert 分类器已完成意图识别:%s +2. FAQ 库未命中标准答案 +3. 向量检索已尝试召回参考片段 + +回答要求: +1. 只能基于下方知识片段作答 +2. 如果知识片段为空或无法支持回答,必须明确回答「未找到相关资料」 +3. 不得闲聊,不得扩展到越域内容,不得编造 +4. 优先给出结论,再给出依据 + +当前页面上下文: +%s + +知识片段: +%s`, intent, string(ctxJSON), kb.String()) +} + +func cosineSimilarity(a, b []float64) float64 { + if len(a) == 0 || len(a) != len(b) { + return 0 + } + var dot, na, nb float64 + for i := range a { + dot += a[i] * b[i] + na += a[i] * a[i] + nb += b[i] * b[i] + } + if na == 0 || nb == 0 { + return 0 + } + return dot / (math.Sqrt(na) * math.Sqrt(nb)) +} diff --git a/eai_ap_app/backend-go/internal/api/knowledge_space.go b/eai_ap_app/backend-go/internal/api/knowledge_space.go new file mode 100644 index 0000000..e5fd5ac --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/knowledge_space.go @@ -0,0 +1,524 @@ +package api + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "eaisalestrain/backend/internal/model" + "eaisalestrain/backend/internal/store" + "eaisalestrain/backend/internal/web" +) + +type spaceMetrics struct { + Documents int `json:"document_count"` + Chunks int `json:"chunk_count"` +} + +var defaultKnowledgeSpaces = []model.KnowledgeSpace{ + {Key: "general", Name: "通用知识库", Description: "面向企业通用制度、规则、案例与产品总览。", Scope: "general", Status: "active", SortOrder: 10, IsDefault: true}, + {Key: "product", Name: "产品知识库", Description: "聚合产品资料、FAQ、对比说明与销售话术。", Scope: "business", Status: "active", SortOrder: 20, IsDefault: true}, + {Key: "training", Name: "培训资料库", Description: "聚合培训课程、训练营资料与学习内容。", Scope: "training", Status: "active", SortOrder: 30, IsDefault: true}, + {Key: "policy", Name: "规则制度库", Description: "聚合制度文件、审批规则与合规要求。", Scope: "governance", Status: "active", SortOrder: 40, IsDefault: true}, +} + +// ListKnowledgeSpaces GET /api/knowledge/spaces +func ListKnowledgeSpaces(c *gin.Context) { + spaces, metrics, err := listKnowledgeSpacesWithMetrics() + if err != nil { + web.Fail(c, web.NewBadRequest("加载知识空间失败")) + return + } + items := make([]gin.H, 0, len(spaces)) + for _, space := range spaces { + m := metrics[space.Key] + items = append(items, gin.H{ + "id": space.ID, + "key": space.Key, + "name": space.Name, + "description": space.Description, + "scope": space.Scope, + "status": space.Status, + "sort_order": space.SortOrder, + "is_default": space.IsDefault, + "document_count": m.Documents, + "chunk_count": m.Chunks, + }) + } + web.OK(c, gin.H{"items": items}) +} + +// CreateKnowledgeSpace POST /api/knowledge/spaces +func CreateKnowledgeSpace(c *gin.Context) { + var req struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Scope string `json:"scope"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + req.Key = sanitizeSpaceKey(req.Key) + if req.Key == "" || strings.TrimSpace(req.Name) == "" { + web.Fail(c, web.NewBadRequest("key 与 name 必填")) + return + } + space := model.KnowledgeSpace{ + Key: req.Key, + Name: strings.TrimSpace(req.Name), + Description: strings.TrimSpace(req.Description), + Scope: orDefault(strings.TrimSpace(req.Scope), "general"), + Status: orDefault(strings.TrimSpace(req.Status), "active"), + SortOrder: req.SortOrder, + } + if err := store.DB.Create(&space).Error; err != nil { + web.Fail(c, web.NewConflictError("知识空间 key 已存在")) + return + } + web.OK(c, gin.H{"item": space}) +} + +// UpdateKnowledgeSpace PUT /api/knowledge/spaces/:id +func UpdateKnowledgeSpace(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + var space model.KnowledgeSpace + if err := store.DB.First(&space, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("知识空间不存在")) + return + } + var req struct { + Name string `json:"name"` + Description string `json:"description"` + Scope string `json:"scope"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + if strings.TrimSpace(req.Name) != "" { + space.Name = strings.TrimSpace(req.Name) + } + space.Description = strings.TrimSpace(req.Description) + if strings.TrimSpace(req.Scope) != "" { + space.Scope = strings.TrimSpace(req.Scope) + } + if strings.TrimSpace(req.Status) != "" { + space.Status = strings.TrimSpace(req.Status) + } + space.SortOrder = req.SortOrder + if err := store.DB.Save(&space).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新知识空间失败")) + return + } + web.OK(c, gin.H{"item": space}) +} + +// DeleteKnowledgeSpace DELETE /api/knowledge/spaces/:id +func DeleteKnowledgeSpace(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + var space model.KnowledgeSpace + if err := store.DB.First(&space, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("知识空间不存在")) + return + } + if space.IsDefault { + web.Fail(c, web.NewConflictError("默认知识空间不可删除")) + return + } + if err := store.DB.Delete(&space).Error; err != nil { + web.Fail(c, web.NewBadRequest("删除知识空间失败")) + return + } + web.OK(c, gin.H{"deleted": true}) +} + +// SearchKnowledge GET /api/knowledge/search?query=&space_key=&limit= +func SearchKnowledge(c *gin.Context) { + query := strings.TrimSpace(c.Query("query")) + spaceKey := strings.TrimSpace(c.Query("space_key")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "6")) + if limit < 1 || limit > 20 { + limit = 6 + } + + if _, err := buildKnowledgeSpaceMetrics(); err != nil { + web.Fail(c, web.NewBadRequest("加载知识引用失败")) + return + } + + var chunks []model.KnowledgeChunk + chunkQuery := store.DB.Order("created_at DESC") + if spaceKey != "" { + // 优先按显式空间过滤;同时保留空值记录作为兼容兜底,避免历史数据完全不可见。 + chunkQuery = chunkQuery.Where("knowledge_space_key = ? OR knowledge_space_key = '' OR knowledge_space_key IS NULL", spaceKey) + } + chunkQuery.Limit(400).Find(&chunks) + + mediaMap := map[uint]model.MediaFile{} + sourceMap := map[uint]model.KnowledgeSource{} + for _, chunk := range chunks { + if chunk.MediaFileID != nil { + if _, ok := mediaMap[*chunk.MediaFileID]; !ok { + var media model.MediaFile + if err := store.DB.First(&media, *chunk.MediaFileID).Error; err == nil { + mediaMap[*chunk.MediaFileID] = media + } + } + } + if chunk.KnowledgeSourceID != nil { + if _, ok := sourceMap[*chunk.KnowledgeSourceID]; !ok { + var source model.KnowledgeSource + if err := store.DB.First(&source, *chunk.KnowledgeSourceID).Error; err == nil { + sourceMap[*chunk.KnowledgeSourceID] = source + } + } + } + } + + type searchHit struct { + Chunk model.KnowledgeChunk + Score int + Title string + Space string + } + hits := make([]searchHit, 0, limit) + terms := buildSearchTerms(query) + for _, chunk := range chunks { + title, resolvedSpace, ok := resolveChunkMeta(chunk, mediaMap, sourceMap) + if !ok { + continue + } + if spaceKey != "" && resolvedSpace != spaceKey { + continue + } + score := scoreChunkMatch(chunk.Content, title, query, terms) + if query != "" && score == 0 { + continue + } + if query == "" { + score = int(chunk.ID) + } + hits = append(hits, searchHit{ + Chunk: chunk, + Score: score, + Title: title, + Space: resolvedSpace, + }) + } + + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].Score == hits[j].Score { + return hits[i].Chunk.CreatedAt.After(hits[j].Chunk.CreatedAt) + } + return hits[i].Score > hits[j].Score + }) + if len(hits) > limit { + hits = hits[:limit] + } + + items := make([]gin.H, 0, len(hits)) + for _, hit := range hits { + spaceName := getKnowledgeSpaceDisplayName(hit.Space) + items = append(items, gin.H{ + "id": hit.Chunk.ID, + "title": hit.Title, + "snippet": buildChunkSnippet(hit.Chunk.Content, query), + "content": hit.Chunk.Content, + "chunk_index": hit.Chunk.ChunkIndex, + "source_type": hit.Chunk.SourceType, + "source_id": hit.Chunk.SourceID, + "knowledge_space_key": hit.Space, + "knowledge_space_name": spaceName, + "created_at": hit.Chunk.CreatedAt, + }) + } + web.OK(c, gin.H{"items": items}) +} + +func listKnowledgeSpacesWithMetrics() ([]model.KnowledgeSpace, map[string]spaceMetrics, error) { + if err := ensureDefaultKnowledgeSpaces(); err != nil { + return nil, nil, err + } + var spaces []model.KnowledgeSpace + if err := store.DB.Order("sort_order ASC, id ASC").Find(&spaces).Error; err != nil { + return nil, nil, err + } + metrics, err := buildKnowledgeSpaceMetrics() + if err != nil { + return nil, nil, err + } + return spaces, metrics, nil +} + +func ensureDefaultKnowledgeSpaces() error { + for _, item := range defaultKnowledgeSpaces { + var existing model.KnowledgeSpace + if err := store.DB.Where("key = ?", item.Key).First(&existing).Error; err == nil { + continue + } + if err := store.DB.Create(&item).Error; err != nil { + return err + } + } + return nil +} + +func buildKnowledgeSpaceMetrics() (map[string]spaceMetrics, error) { + metrics := map[string]spaceMetrics{} + for _, item := range defaultKnowledgeSpaces { + metrics[item.Key] = spaceMetrics{} + } + + var mediaFiles []model.MediaFile + if err := store.DB.Where("status = ?", "approved").Find(&mediaFiles).Error; err != nil { + return nil, err + } + for _, media := range mediaFiles { + key := resolveMediaKnowledgeSpaceKey(media) + m := metrics[key] + m.Documents++ + metrics[key] = m + // 通用空间汇总所有已通过文档 + if key != "general" { + g := metrics["general"] + g.Documents++ + metrics["general"] = g + } + } + + var sources []model.KnowledgeSource + if err := store.DB.Where("audit_status = ?", "approved").Find(&sources).Error; err != nil { + return nil, err + } + for _, source := range sources { + key := resolveKnowledgeSourceSpaceKey(source) + m := metrics[key] + m.Documents++ + metrics[key] = m + if key != "general" { + g := metrics["general"] + g.Documents++ + metrics["general"] = g + } + } + + var chunks []model.KnowledgeChunk + if err := store.DB.Find(&chunks).Error; err != nil { + return nil, err + } + for _, chunk := range chunks { + key := ensureKnowledgeSpaceKeyOrDefault(chunk.KnowledgeSpaceKey) + if chunk.KnowledgeSourceID != nil { + var src model.KnowledgeSource + if err := store.DB.First(&src, *chunk.KnowledgeSourceID).Error; err == nil { + key = resolveKnowledgeSourceSpaceKey(src) + } + } else if chunk.MediaFileID != nil { + var media model.MediaFile + if err := store.DB.First(&media, *chunk.MediaFileID).Error; err == nil { + key = resolveMediaKnowledgeSpaceKey(media) + } + } else if key == "general" { + key = inferKnowledgeSpaceKey(chunk.Content, "", chunk.SourceType) + } + m := metrics[key] + m.Chunks++ + metrics[key] = m + if key != "general" { + g := metrics["general"] + g.Chunks++ + metrics["general"] = g + } + } + return metrics, nil +} + +func getKnowledgeSpaceDisplayName(key string) string { + for _, item := range defaultKnowledgeSpaces { + if item.Key == key { + return item.Name + } + } + var space model.KnowledgeSpace + if err := store.DB.Where("key = ?", key).First(&space).Error; err == nil && strings.TrimSpace(space.Name) != "" { + return space.Name + } + return key +} + +func inferKnowledgeSpaceKey(title, domain, extra string) string { + text := strings.ToLower(strings.Join([]string{title, domain, extra}, " ")) + switch { + case domain == "product" || strings.Contains(text, "产品") || strings.Contains(text, "faq") || strings.Contains(text, "product"): + return "product" + case strings.Contains(text, "培训") || strings.Contains(text, "训练") || strings.Contains(text, "课程") || strings.Contains(text, "sales") || domain == "sales" || domain == "company": + return "training" + case strings.Contains(text, "规则") || strings.Contains(text, "制度") || strings.Contains(text, "合规") || strings.Contains(text, "policy") || strings.Contains(text, "rule"): + return "policy" + default: + return "general" + } +} + +func sanitizeSpaceKey(key string) string { + key = strings.TrimSpace(strings.ToLower(key)) + key = strings.ReplaceAll(key, " ", "-") + key = strings.ReplaceAll(key, "_", "-") + var b strings.Builder + for _, ch := range key { + if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' { + b.WriteRune(ch) + } + } + return strings.Trim(b.String(), "-") +} + +func ensureKnowledgeSpaceKeyOrDefault(key string) string { + key = sanitizeSpaceKey(key) + if key == "" { + return "general" + } + if err := ensureDefaultKnowledgeSpaces(); err == nil { + for _, item := range defaultKnowledgeSpaces { + if item.Key == key { + return key + } + } + } + var space model.KnowledgeSpace + if err := store.DB.Where("key = ?", key).First(&space).Error; err == nil { + return key + } + return "general" +} + +func resolveMediaKnowledgeSpaceKey(media model.MediaFile) string { + if key := ensureKnowledgeSpaceKeyOrDefault(media.KnowledgeSpaceKey); key != "general" || sanitizeSpaceKey(media.KnowledgeSpaceKey) != "" { + return key + } + return inferKnowledgeSpaceKey(media.Filename, "", media.FileExt) +} + +func resolveKnowledgeSourceSpaceKey(source model.KnowledgeSource) string { + if key := ensureKnowledgeSpaceKeyOrDefault(source.KnowledgeSpaceKey); key != "general" || sanitizeSpaceKey(source.KnowledgeSpaceKey) != "" { + return key + } + return inferKnowledgeSpaceKey(source.Title, source.Domain, source.Category+" "+source.FilePath) +} + +func buildSearchTerms(query string) []string { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return nil + } + parts := strings.FieldsFunc(query, func(r rune) bool { + return r == ' ' || r == ',' || r == ',' || r == '。' || r == ';' || r == ';' + }) + seen := map[string]bool{query: true} + terms := []string{query} + for _, part := range parts { + part = strings.TrimSpace(part) + if len([]rune(part)) < 2 || seen[part] { + continue + } + seen[part] = true + terms = append(terms, part) + } + return terms +} + +func scoreChunkMatch(content, title, query string, terms []string) int { + text := strings.ToLower(title + "\n" + content) + score := 0 + if query != "" && strings.Contains(text, strings.ToLower(query)) { + score += 8 + } + for _, term := range terms { + if strings.Contains(text, term) { + score += 2 + } + } + return score +} + +func resolveChunkMeta(chunk model.KnowledgeChunk, mediaMap map[uint]model.MediaFile, sourceMap map[uint]model.KnowledgeSource) (string, string, bool) { + if chunk.MediaFileID != nil { + media, ok := mediaMap[*chunk.MediaFileID] + if !ok || media.Status != "approved" { + return "", "", false + } + return media.Filename, resolveMediaKnowledgeSpaceKey(media), true + } + if chunk.KnowledgeSourceID != nil { + source, ok := sourceMap[*chunk.KnowledgeSourceID] + if !ok || source.AuditStatus != "approved" { + return "", "", false + } + return source.Title, resolveKnowledgeSourceSpaceKey(source), true + } + title := fmt.Sprintf("%s-%s", chunk.SourceType, chunk.SourceID) + if key := sanitizeSpaceKey(chunk.KnowledgeSpaceKey); key != "" { + return title, ensureKnowledgeSpaceKeyOrDefault(key), true + } + return title, inferKnowledgeSpaceKey(title, "", chunk.SourceType), true +} + +func buildChunkSnippet(content, query string) string { + content = strings.TrimSpace(content) + if content == "" { + return "" + } + if query == "" || len([]rune(content)) <= 120 { + return content + } + lowerContent := strings.ToLower(content) + lowerQuery := strings.ToLower(strings.TrimSpace(query)) + idx := strings.Index(lowerContent, lowerQuery) + if idx < 0 { + runes := []rune(content) + if len(runes) > 120 { + return string(runes[:120]) + "..." + } + return content + } + runes := []rune(content) + start := max(idx-30, 0) + end := min(idx+90, len(runes)) + snippet := string(runes[start:end]) + if start > 0 { + snippet = "..." + snippet + } + if end < len(runes) { + snippet += "..." + } + return snippet +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/eai_ap_app/backend-go/internal/api/media.go b/eai_ap_app/backend-go/internal/api/media.go index 21a5943..0f39162 100644 --- a/eai_ap_app/backend-go/internal/api/media.go +++ b/eai_ap_app/backend-go/internal/api/media.go @@ -87,6 +87,14 @@ func parseBind(c *gin.Context) (string, *uint) { return bt, bid } +func parseKnowledgeSpaceKey(c *gin.Context) string { + key := sanitizeSpaceKey(c.PostForm("knowledge_space_key")) + if key == "" { + return "general" + } + return ensureKnowledgeSpaceKeyOrDefault(key) +} + func sourceOf(c *gin.Context) string { u := middleware.CurrentUser(c) if u != nil && u.Role == "admin" { @@ -122,6 +130,7 @@ func Upload(c *gin.Context) { } bindType, bindID := parseBind(c) + knowledgeSpaceKey := parseKnowledgeSpaceKey(c) source := sourceOf(c) status := "pending" targetDir := pendingDir() @@ -149,16 +158,17 @@ func Upload(c *gin.Context) { out.Close() m := model.MediaFile{ - Filename: header.Filename, - StoredName: storedName, - StoredPath: storedName, - FileExt: ext, - FileSize: header.Size, - Status: status, - Source: source, - SubmitterID: u.ID, - BindType: bindType, - BindID: bindID, + Filename: header.Filename, + StoredName: storedName, + StoredPath: storedName, + FileExt: ext, + FileSize: header.Size, + Status: status, + Source: source, + SubmitterID: u.ID, + BindType: bindType, + BindID: bindID, + KnowledgeSpaceKey: knowledgeSpaceKey, } if err := store.DB.Create(&m).Error; err != nil { web.Fail(c, web.NewBadRequest("创建素材记录失败")) @@ -173,14 +183,15 @@ func Upload(c *gin.Context) { // ============ 分片上传 ============ type uploadSession struct { - Filename string - FileSize int64 - Ext string - BindType string - BindID *uint - ChunkSize int64 - ChunkCount int - Chunks map[int]bool + Filename string + FileSize int64 + Ext string + BindType string + BindID *uint + ChunkSize int64 + ChunkCount int + Chunks map[int]bool + KnowledgeSpaceKey string } var uploadSessions = struct { @@ -191,10 +202,11 @@ var uploadSessions = struct { // UploadInit POST /api/media/upload-init —— 初始化分片上传 func UploadInit(c *gin.Context) { var req struct { - Filename string `json:"filename"` - FileSize int64 `json:"file_size"` - BindType string `json:"bind_type"` - BindID *uint `json:"bind_id"` + Filename string `json:"filename"` + FileSize int64 `json:"file_size"` + BindType string `json:"bind_type"` + BindID *uint `json:"bind_id"` + KnowledgeSpaceKey string `json:"knowledge_space_key"` } if err := c.ShouldBindJSON(&req); err != nil || req.Filename == "" || req.FileSize <= 0 { web.Fail(c, web.NewBadRequest("filename/file_size 必填")) @@ -220,6 +232,7 @@ func UploadInit(c *gin.Context) { BindType: req.BindType, BindID: req.BindID, ChunkSize: defaultChunkSize, ChunkCount: chunkCount, Chunks: map[int]bool{}, } + sess.KnowledgeSpaceKey = ensureKnowledgeSpaceKeyOrDefault(sanitizeSpaceKey(req.KnowledgeSpaceKey)) uploadSessions.Lock() uploadSessions.m[id] = sess uploadSessions.Unlock() @@ -346,6 +359,7 @@ func UploadComplete(c *gin.Context) { Filename: sess.Filename, StoredName: storedName, StoredPath: storedName, FileExt: sess.Ext, FileSize: sess.FileSize, Status: status, Source: source, SubmitterID: u.ID, BindType: sess.BindType, BindID: sess.BindID, + KnowledgeSpaceKey: sess.KnowledgeSpaceKey, } if err := store.DB.Create(&m).Error; err != nil { web.Fail(c, web.NewBadRequest("创建素材记录失败")) @@ -394,7 +408,7 @@ func MediaStatus(c *gin.Context) { } var chunkCount int64 store.DB.Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", m.ID).Count(&chunkCount) - web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount}) + web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount, "knowledge_space_key": m.KnowledgeSpaceKey}) } // ============ 审批 ============ @@ -405,6 +419,9 @@ func AuditList(c *gin.Context) { if s := c.Query("status"); s != "" { q = q.Where("status = ?", s) } + if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" { + q = q.Where("knowledge_space_key = ?", key) + } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) if page < 1 { @@ -531,14 +548,16 @@ func runExtractPipeline(mediaID uint) { continue } store.DB.Create(&model.KnowledgeChunk{ - MediaFileID: &m.ID, - SourceType: m.FileExt, - SourceID: strconv.FormatUint(uint64(m.ID), 10), - ChunkIndex: i, - Content: chunk, + MediaFileID: &m.ID, + SourceType: m.FileExt, + SourceID: strconv.FormatUint(uint64(m.ID), 10), + KnowledgeSpaceKey: resolveMediaKnowledgeSpaceKey(m), + ChunkIndex: i, + Content: chunk, }) } store.DB.Model(&m).Update("extracted", true) + triggerKnowledgeIndexRebuild() log.Printf("[提取完成] media_id=%d chunks=%d", mediaID, len(chunks)) } diff --git a/eai_ap_app/backend-go/internal/api/notification.go b/eai_ap_app/backend-go/internal/api/notification.go index 2200c29..f43fb50 100644 --- a/eai_ap_app/backend-go/internal/api/notification.go +++ b/eai_ap_app/backend-go/internal/api/notification.go @@ -1,6 +1,8 @@ package api import ( + "strings" + "github.com/gin-gonic/gin" "eaisalestrain/backend/internal/middleware" @@ -28,6 +30,26 @@ func notifyAllEmployees(ntype, title, content, link string) { } } +func notifyAdmins(ntype, title, content, link string) { + var users []model.User + store.DB.Where("role = ? AND status = ?", "admin", "active").Select("id").Find(&users) + for _, u := range users { + notifyUser(u.ID, ntype, title, content, link) + } +} + +func notifyUsersByOwner(owner, ntype, title, content, link string) { + owner = strings.TrimSpace(owner) + if owner == "" || owner == "待指派" { + return + } + var users []model.User + store.DB.Where("status = ? AND (full_name = ? OR username = ?)", "active", owner, owner).Select("id").Find(&users) + for _, u := range users { + notifyUser(u.ID, ntype, title, content, link) + } +} + // MyNotifications GET /api/notifications?unread_only=true —— 我的通知列表 func MyNotifications(c *gin.Context) { u := middleware.CurrentUser(c) diff --git a/eai_ap_app/backend-go/internal/api/router.go b/eai_ap_app/backend-go/internal/api/router.go index 72e6221..3be2be5 100644 --- a/eai_ap_app/backend-go/internal/api/router.go +++ b/eai_ap_app/backend-go/internal/api/router.go @@ -43,6 +43,10 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { r.GET("/api/specialists", middleware.Auth(cfg), ListSpecialists) r.GET("/api/specialists/by-key/:key", middleware.Auth(cfg), GetSpecialistByKey) r.GET("/api/specialists/summary", middleware.Auth(cfg), SpecialistSummary) + r.GET("/api/workbench/overview", middleware.Auth(cfg), WorkbenchOverview) + r.GET("/api/worker/tasks", middleware.Auth(cfg), ListWorkerTasks) + r.GET("/api/worker/tasks/:id", middleware.Auth(cfg), GetWorkerTaskDetail) + r.GET("/api/worker/artifacts/:id", middleware.Auth(cfg), GetWorkerArtifactDetail) r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors) r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector) r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector) @@ -84,6 +88,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { r.GET("/api/media/preview/:mediaId", middleware.Auth(cfg), Preview) r.GET("/api/media/status/:mediaId", middleware.Auth(cfg), MediaStatus) + r.GET("/api/knowledge/spaces", middleware.Auth(cfg), ListKnowledgeSpaces) + r.GET("/api/knowledge/search", middleware.Auth(cfg), SearchKnowledge) r.GET("/api/knowledge/status/:sourceId", middleware.Auth(cfg), KnowledgeStatus) // ── AI 对话(普通员工可访问,管理员访问)── @@ -130,6 +136,14 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { admin.POST("/media/audit/:mediaId", AuditMedia) admin.POST("/knowledge/scan", KnowledgeScan) + admin.POST("/knowledge/index/rebuild", RebuildKnowledgeIndex) + admin.POST("/knowledge/spaces", CreateKnowledgeSpace) + admin.PUT("/knowledge/spaces/:id", UpdateKnowledgeSpace) + admin.DELETE("/knowledge/spaces/:id", DeleteKnowledgeSpace) + admin.GET("/knowledge/faqs", ListKnowledgeFAQs) + admin.POST("/knowledge/faqs", CreateKnowledgeFAQ) + admin.PUT("/knowledge/faqs/:id", UpdateKnowledgeFAQ) + admin.DELETE("/knowledge/faqs/:id", DeleteKnowledgeFAQ) admin.GET("/knowledge/audit-list", KnowledgeAuditList) admin.POST("/knowledge/audit/:sourceId", KnowledgeAudit) admin.GET("/knowledge/export", ExportKnowledge) @@ -141,6 +155,12 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { admin.POST("/specialists", CreateSpecialist) admin.PUT("/specialists/:id", UpdateSpecialist) admin.DELETE("/specialists/:id", DeleteSpecialist) + admin.POST("/worker/tasks", CreateWorkerTask) + admin.PUT("/worker/tasks/:id", UpdateWorkerTask) + admin.PUT("/worker/tasks/:id/status", UpdateWorkerTaskStatus) + admin.DELETE("/worker/tasks/:id", DeleteWorkerTask) + admin.POST("/worker/tasks/:id/actions", ExecuteWorkerTaskAction) + admin.PUT("/worker/artifacts/:id/status", UpdateWorkerArtifactStatus) admin.GET("/positions/:id/knowledge", ListPositionKnowledge) admin.PUT("/positions/:id/knowledge", SavePositionKnowledge) admin.GET("/positions/:id/blueprint", ListPositionBlueprint) diff --git a/eai_ap_app/backend-go/internal/api/workbench_overview.go b/eai_ap_app/backend-go/internal/api/workbench_overview.go new file mode 100644 index 0000000..f8cbadf --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/workbench_overview.go @@ -0,0 +1,213 @@ +package api + +import ( + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "eaisalestrain/backend/internal/middleware" + "eaisalestrain/backend/internal/model" + "eaisalestrain/backend/internal/store" + "eaisalestrain/backend/internal/web" +) + +func WorkbenchOverview(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + + openStatuses := []string{ + workerTaskStatusPending, + workerTaskStatusInProgress, + workerTaskStatusDraft, + workerTaskStatusPendingReview, + } + closedStatuses := []string{ + workerTaskStatusCompleted, + workerTaskStatusArchived, + } + + taskQuery := scopedWorkbenchTaskQuery(user) + + var todoCount int64 + if err := taskQuery.Where("status IN ?", openStatuses).Count(&todoCount).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询工作台待办失败")) + return + } + + var tasks []model.WorkerTask + if err := scopedWorkbenchTaskQuery(user). + Where("status IN ?", openStatuses). + Order("CASE priority WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END"). + Order("updated_at DESC, id DESC"). + Limit(6). + Find(&tasks).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询工作台事项失败")) + return + } + + var riskTasks []model.WorkerTask + if err := scopedWorkbenchTaskQuery(user). + Where("(status = ? OR priority = ?) AND status NOT IN ?", workerTaskStatusPendingReview, "P1", closedStatuses). + Order("updated_at DESC, id DESC"). + Limit(4). + Find(&riskTasks).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询风险事项失败")) + return + } + + artifactQuery := store.DB.Table("worker_artifact"). + Select("worker_artifact.id, worker_artifact.title, worker_artifact.status, worker_artifact.artifact_type, worker_artifact.created_at, worker_task.id as task_id, worker_task.title as task_title, worker_task.specialist_key"). + Joins("left join worker_task on worker_task.id = worker_artifact.task_id") + if user.Role != "admin" { + artifactQuery = artifactQuery.Where("worker_task.owner IN ?", []string{user.FullName, user.Username}) + } + + type artifactRow struct { + ID uint `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + ArtifactType string `json:"artifact_type"` + TaskID uint `json:"task_id"` + TaskTitle string `json:"task_title"` + SpecialistKey string `json:"specialist_key"` + CreatedAt time.Time `json:"created_at"` + } + var artifactRows []artifactRow + if err := artifactQuery.Order("worker_artifact.created_at DESC, worker_artifact.id DESC").Limit(6).Scan(&artifactRows).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询最近交付物失败")) + return + } + + specialistKeys := make([]string, 0, len(tasks)+len(riskTasks)+len(artifactRows)) + for _, item := range tasks { + specialistKeys = append(specialistKeys, item.SpecialistKey) + } + for _, item := range riskTasks { + specialistKeys = append(specialistKeys, item.SpecialistKey) + } + for _, item := range artifactRows { + specialistKeys = append(specialistKeys, item.SpecialistKey) + } + specialistMap := loadSpecialistMap(specialistKeys) + + todoItems := make([]gin.H, 0, len(tasks)) + for _, item := range tasks { + spec := specialistMap[item.SpecialistKey] + todoItems = append(todoItems, gin.H{ + "id": item.ID, + "title": item.Title, + "summary": item.Summary, + "status": item.Status, + "priority": item.Priority, + "owner": item.Owner, + "specialist_key": item.SpecialistKey, + "specialist": spec.Label, + "route": spec.Route, + }) + } + + riskItems := make([]gin.H, 0, len(riskTasks)) + for _, item := range riskTasks { + spec := specialistMap[item.SpecialistKey] + riskItems = append(riskItems, gin.H{ + "id": item.ID, + "title": item.Title, + "sub": buildWorkbenchRiskText(item), + "status": item.Status, + "route": spec.Route, + "specialist": spec.Label, + }) + } + + recentArtifacts := make([]gin.H, 0, len(artifactRows)) + for _, item := range artifactRows { + spec := specialistMap[item.SpecialistKey] + recentArtifacts = append(recentArtifacts, gin.H{ + "id": item.ID, + "title": item.Title, + "sub": strings.TrimSpace(spec.Label + " / " + item.TaskTitle), + "tag": artifactStatusLabel(item.Status), + "status": item.Status, + "task_id": item.TaskID, + "specialist_key": item.SpecialistKey, + "route": spec.Route, + "artifact_type": item.ArtifactType, + "created_at": item.CreatedAt, + }) + } + + web.OK(c, gin.H{ + "todo_count": todoCount, + "todo_items": todoItems, + "recent_artifacts": recentArtifacts, + "risk_items": riskItems, + }) +} + +func scopedWorkbenchTaskQuery(user *model.User) *gorm.DB { + query := store.DB.Model(&model.WorkerTask{}) + if user != nil && user.Role != "admin" { + query = query.Where("owner IN ?", []string{user.FullName, user.Username}) + } + return query +} + +func loadSpecialistMap(keys []string) map[string]model.Specialist { + filtered := make([]string, 0, len(keys)) + seen := map[string]struct{}{} + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + filtered = append(filtered, key) + } + if len(filtered) == 0 { + return map[string]model.Specialist{} + } + var items []model.Specialist + store.DB.Where("key IN ?", filtered).Find(&items) + out := make(map[string]model.Specialist, len(items)) + for _, item := range items { + out[item.Key] = item + } + return out +} + +func buildWorkbenchRiskText(task model.WorkerTask) string { + switch task.Status { + case workerTaskStatusPendingReview: + return "当前事项正在等待确认,建议优先处理。" + case workerTaskStatusDraft: + return "草案已生成,仍需确认和发布。" + default: + if task.Priority == "P1" { + return "P1 高优事项仍未闭环。" + } + return "当前事项仍在推进中,需要继续跟进。" + } +} + +func artifactStatusLabel(status string) string { + switch normalizeWorkerArtifactStatus(status) { + case workerArtifactStatusDraft: + return "草案" + case workerArtifactStatusReady: + return "待确认" + case workerArtifactStatusApproved: + return "已确认" + case workerArtifactStatusPublished: + return "已发布" + default: + return "已生成" + } +} diff --git a/eai_ap_app/backend-go/internal/api/worker_task.go b/eai_ap_app/backend-go/internal/api/worker_task.go new file mode 100644 index 0000000..71b65b6 --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/worker_task.go @@ -0,0 +1,1080 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "eaisalestrain/backend/internal/ai" + "eaisalestrain/backend/internal/config" + "eaisalestrain/backend/internal/connector" + "eaisalestrain/backend/internal/middleware" + "eaisalestrain/backend/internal/model" + "eaisalestrain/backend/internal/store" + "eaisalestrain/backend/internal/web" +) + +type workerTaskReq struct { + SpecialistKey string `json:"specialist_key"` + Title string `json:"title"` + Summary string `json:"summary"` + Owner string `json:"owner"` + Priority string `json:"priority"` + Status string `json:"status"` + DueAt string `json:"due_at"` + Context map[string]any `json:"context"` +} + +type workerActionReq struct { + ActionKey string `json:"action_key"` + ActionTitle string `json:"action_title"` + ActionType string `json:"action_type"` + ActionDetail string `json:"action_detail"` + Inputs []string `json:"inputs"` + Outputs []string `json:"outputs"` + Approval string `json:"approval"` +} + +type workerTaskStatusReq struct { + Status string `json:"status"` +} + +type workerArtifactStatusReq struct { + Status string `json:"status"` + Remark string `json:"remark"` +} + +func ListWorkerTasks(c *gin.Context) { + specialistKey := strings.TrimSpace(c.Query("specialist_key")) + if specialistKey == "" { + web.Fail(c, web.NewBadRequest("specialist_key 不能为空")) + return + } + + var items []model.WorkerTask + if err := store.DB.Where("specialist_key = ?", specialistKey).Order("updated_at DESC, id DESC").Find(&items).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询事项失败")) + return + } + web.OK(c, items) +} + +func GetWorkerTaskDetail(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.WorkerTask + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var artifacts []model.WorkerArtifact + if err := store.DB.Where("task_id = ?", id).Order("created_at DESC, id DESC").Find(&artifacts).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询交付物失败")) + return + } + + var runs []model.WorkerRun + if err := store.DB.Where("task_id = ?", id).Order("started_at DESC, id DESC").Find(&runs).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询运行记录失败")) + return + } + + web.OK(c, gin.H{ + "task": task, + "artifacts": artifacts, + "runs": runs, + "bottom_panel": buildBottomPanel(runs), + }) +} + +func GetWorkerArtifactDetail(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var artifact model.WorkerArtifact + if err := store.DB.First(&artifact, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("交付物不存在")) + return + } + + var task model.WorkerTask + if err := store.DB.First(&task, artifact.TaskID).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var run model.WorkerRun + var runData any + if artifact.CreatedByRunID != nil { + if err := store.DB.First(&run, *artifact.CreatedByRunID).Error; err == nil { + runData = run + } + } + + web.OK(c, gin.H{ + "artifact": artifact, + "task": task, + "run": runData, + }) +} + +func CreateWorkerTask(c *gin.Context) { + var req workerTaskReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + if strings.TrimSpace(req.SpecialistKey) == "" || strings.TrimSpace(req.Title) == "" { + web.Fail(c, web.NewBadRequest("specialist_key、title 为必填")) + return + } + + var specialist model.Specialist + if err := store.DB.Where("key = ?", strings.TrimSpace(req.SpecialistKey)).First(&specialist).Error; err != nil { + web.Fail(c, web.NewNotFoundError("专员不存在")) + return + } + + task, err := buildWorkerTaskFromReq(req, middleware.CurrentUser(c)) + if err != nil { + web.Fail(c, web.NewBadRequest(err.Error())) + return + } + task.SpecialistKey = specialist.Key + if task.Status == "" { + task.Status = specialist.Stage + } + if task.Status == "" { + task.Status = "待处理" + } + + if err := store.DB.Create(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("创建事项失败")) + return + } + notifyUsersByOwner( + task.Owner, + "worker_task_assign", + fmt.Sprintf("你有新的待办事项:%s", task.Title), + fmt.Sprintf("专员 %s 已为你创建事项,请尽快查看。", specialist.Label), + specialist.Route, + ) + web.OK(c, task) +} + +func UpdateWorkerTask(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.WorkerTask + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var req workerTaskReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + + updated, err := buildWorkerTaskFromReq(req, middleware.CurrentUser(c)) + if err != nil { + web.Fail(c, web.NewBadRequest(err.Error())) + return + } + + if strings.TrimSpace(req.SpecialistKey) != "" && strings.TrimSpace(req.SpecialistKey) != task.SpecialistKey { + var specialist model.Specialist + if err := store.DB.Where("key = ?", strings.TrimSpace(req.SpecialistKey)).First(&specialist).Error; err != nil { + web.Fail(c, web.NewNotFoundError("目标专员不存在")) + return + } + task.SpecialistKey = specialist.Key + } + previousOwner := task.Owner + + task.Title = updated.Title + task.Summary = updated.Summary + task.Owner = updated.Owner + task.Priority = updated.Priority + task.Status = normalizeWorkerTaskStatus(updated.Status) + task.ContextJSON = updated.ContextJSON + task.DueAt = updated.DueAt + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新事项失败")) + return + } + if strings.TrimSpace(task.Owner) != "" && strings.TrimSpace(task.Owner) != strings.TrimSpace(previousOwner) { + notifyUsersByOwner( + task.Owner, + "worker_task_assign", + fmt.Sprintf("你被指派了事项:%s", task.Title), + "请进入数字员工工作区查看最新事项安排。", + buildWorkerTaskLink(task.SpecialistKey), + ) + } + web.OK(c, task) +} + +func UpdateWorkerTaskStatus(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.WorkerTask + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var req workerTaskStatusReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + status := normalizeWorkerTaskStatus(req.Status) + if !isAllowedWorkerTaskStatus(status) { + web.Fail(c, web.NewBadRequest("事项状态不合法")) + return + } + task.Status = status + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新事项状态失败")) + return + } + web.OK(c, task) +} + +func DeleteWorkerTask(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.WorkerTask + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + if err := store.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("task_id = ?", task.ID).Delete(&model.WorkerArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("task_id = ?", task.ID).Delete(&model.WorkerRun{}).Error; err != nil { + return err + } + if err := tx.Delete(&task).Error; err != nil { + return err + } + return nil + }); err != nil { + web.Fail(c, web.NewBadRequest("删除事项失败")) + return + } + + web.OK(c, gin.H{"id": id, "deleted": true, "specialist_key": task.SpecialistKey}) +} + +func UpdateWorkerArtifactStatus(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var artifact model.WorkerArtifact + if err := store.DB.First(&artifact, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("交付物不存在")) + return + } + var task model.WorkerTask + if err := store.DB.First(&task, artifact.TaskID).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var req workerArtifactStatusReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + nextStatus := normalizeWorkerArtifactStatus(req.Status) + if !canTransitionWorkerArtifactStatus(artifact.Status, nextStatus) { + web.Fail(c, web.NewBadRequest("当前交付物状态不允许这样流转")) + return + } + artifact.Status = nextStatus + if err := store.DB.Save(&artifact).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新交付物状态失败")) + return + } + + task.Status = taskStatusFromArtifactStatus(nextStatus) + task.CurrentResult = buildArtifactStatusSummary(artifact, strings.TrimSpace(req.Remark)) + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新事项状态失败")) + return + } + + notifyOnArtifactStatusChange(task, artifact) + + web.OK(c, gin.H{ + "artifact": artifact, + "task": task, + }) +} + +func ExecuteWorkerTaskAction(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.WorkerTask + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + var specialist model.Specialist + if err := store.DB.Where("key = ?", task.SpecialistKey).First(&specialist).Error; err != nil { + web.Fail(c, web.NewNotFoundError("专员不存在")) + return + } + user := middleware.CurrentUser(c) + + var req workerActionReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + req.ActionKey = normalizeWorkerActionKey(req.ActionKey, req.ActionTitle) + req.ActionTitle = strings.TrimSpace(req.ActionTitle) + req.ActionType = strings.TrimSpace(req.ActionType) + req.ActionDetail = strings.TrimSpace(req.ActionDetail) + req.Approval = strings.TrimSpace(req.Approval) + if req.ActionKey == "" || req.ActionTitle == "" { + web.Fail(c, web.NewBadRequest("action_key、action_title 为必填")) + return + } + + now := time.Now() + runOutput := buildWorkerRunOutput(task, specialist, req, user, now) + outputJSON, _ := json.Marshal(runOutput) + logsJSON, _ := json.Marshal(runOutput.Logs) + inputJSON, _ := json.Marshal(gin.H{ + "task_title": task.Title, + "task_status": task.Status, + "inputs": req.Inputs, + "outputs": req.Outputs, + "approval": req.Approval, + }) + + run := model.WorkerRun{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + ActionKey: req.ActionKey, + ActionTitle: req.ActionTitle, + ActionType: req.ActionType, + Status: "done", + InputJSON: string(inputJSON), + OutputJSON: string(outputJSON), + LogsJSON: string(logsJSON), + StartedAt: now, + FinishedAt: &now, + } + if err := store.DB.Create(&run).Error; err != nil { + web.Fail(c, web.NewBadRequest("执行动作失败")) + return + } + + var artifact *model.WorkerArtifact + if runOutput.ArtifactTitle != "" { + sourceRefsJSON, _ := json.Marshal(runOutput.SourceRefs) + artifact = &model.WorkerArtifact{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + Title: runOutput.ArtifactTitle, + ArtifactType: runOutput.ArtifactType, + Status: runOutput.ArtifactStatus, + ContentText: runOutput.ArtifactContent, + ContentJSON: string(outputJSON), + SourceRefsJSON: string(sourceRefsJSON), + CreatedByRunID: &run.ID, + } + if err := store.DB.Create(artifact).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存交付物失败")) + return + } + } + + task.Status = runOutput.TaskStatus + task.CurrentRunID = &run.ID + task.CurrentResult = runOutput.Summary + task.LastTriggeredAt = &now + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新事项状态失败")) + return + } + + web.OK(c, gin.H{ + "task": task, + "run": run, + "artifact": artifact, + }) +} + +func ensureBootstrapWorkerTask(specialistKey string, user *model.User) error { + var count int64 + if err := store.DB.Model(&model.WorkerTask{}).Where("specialist_key = ?", specialistKey).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + + var specialist model.Specialist + if err := store.DB.Where("key = ?", specialistKey).First(&specialist).Error; err != nil { + return err + } + + now := time.Now() + dueAt := now.Add(24 * time.Hour) + contextJSON, _ := json.Marshal(gin.H{ + "bootstrap": true, + "specialist_key": specialist.Key, + "specialist": specialist.Label, + "route": specialist.Route, + }) + + task := model.WorkerTask{ + SpecialistKey: specialist.Key, + Title: defaultWorkerTaskTitle(specialist), + Summary: defaultWorkerTaskSummary(specialist), + Owner: bootstrapOwner(user), + Priority: "P1", + Status: defaultWorkerTaskStatus(specialist), + ContextJSON: string(contextJSON), + DueAt: &dueAt, + } + if user != nil { + task.CreatedBy = &user.ID + } + return store.DB.Create(&task).Error +} + +func buildWorkerTaskFromReq(req workerTaskReq, user *model.User) (model.WorkerTask, error) { + task := model.WorkerTask{ + SpecialistKey: strings.TrimSpace(req.SpecialistKey), + Title: strings.TrimSpace(req.Title), + Summary: strings.TrimSpace(req.Summary), + Owner: strings.TrimSpace(req.Owner), + Priority: strings.TrimSpace(req.Priority), + Status: normalizeWorkerTaskStatus(req.Status), + } + if task.Owner == "" { + task.Owner = bootstrapOwner(user) + } + if task.Priority == "" { + task.Priority = "P2" + } + if task.Status == "" { + task.Status = workerTaskStatusPending + } + if len(req.Context) > 0 { + b, err := json.Marshal(req.Context) + if err != nil { + return task, fmt.Errorf("context 序列化失败") + } + task.ContextJSON = string(b) + } + if strings.TrimSpace(req.DueAt) != "" { + parsed, err := parseWorkerDueAt(req.DueAt) + if err != nil { + return task, fmt.Errorf("due_at 格式错误,支持 RFC3339 或 2006-01-02 15:04") + } + task.DueAt = &parsed + } + if user != nil { + task.CreatedBy = &user.ID + } + return task, nil +} + +func parseWorkerDueAt(value string) (time.Time, error) { + value = strings.TrimSpace(value) + layouts := []string{time.RFC3339, "2006-01-02 15:04", "2006-01-02"} + var lastErr error + for _, layout := range layouts { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed, nil + } + lastErr = err + } + return time.Time{}, lastErr +} + +type workerRunOutput struct { + Summary string `json:"summary"` + TaskStatus string `json:"task_status"` + ArtifactTitle string `json:"artifact_title"` + ArtifactType string `json:"artifact_type"` + ArtifactStatus string `json:"artifact_status"` + ArtifactContent string `json:"artifact_content"` + SourceRefs []string `json:"source_refs"` + Timeline []string `json:"timeline"` + Trace []string `json:"trace"` + Logs []string `json:"logs"` +} + +func buildWorkerRunOutput(task model.WorkerTask, specialist model.Specialist, req workerActionReq, user *model.User, now time.Time) workerRunOutput { + status := workerTaskStatusInProgress + artifactType := "text" + artifactStatus := workerArtifactStatusDraft + artifactTitle := fmt.Sprintf("%s - %s", task.Title, req.ActionTitle) + summary := fmt.Sprintf("%s已执行,事项已更新。", req.ActionTitle) + + switch { + case containsAny(req.ActionTitle, "生成", "汇总", "草案", "方案"): + status = workerTaskStatusDraft + artifactType = "draft" + artifactStatus = workerArtifactStatusDraft + summary = fmt.Sprintf("已生成《%s》草案,等待人工确认。", task.Title) + case containsAny(req.ActionTitle, "审批", "确认", "评审", "提交"): + status = workerTaskStatusPendingReview + artifactType = "approval_note" + artifactStatus = workerArtifactStatusReady + summary = fmt.Sprintf("已提交《%s》的确认请求。", task.Title) + case containsAny(req.ActionTitle, "发布", "归档", "同步"): + status = workerTaskStatusArchived + artifactType = "delivery" + artifactStatus = workerArtifactStatusPublished + summary = fmt.Sprintf("《%s》已进入发布/归档阶段。", task.Title) + } + + strategy := chooseWorkerStrategy(specialist, req) + connectorSummary, connectorSourceRefs, connectorTrace, connectorLogs := executeConnectorStep(specialist, task, req, strategy.ConnectorKey) + aiSummary, aiTrace, aiLogs := executeAIStep(task, specialist, req, user, strategy.UseAI) + + content := fmt.Sprintf( + "%s\n\n事项:%s\n当前摘要:%s\n动作说明:%s\n输入:%s\n预期输出:%s\n连接器结果:%s\nAI结果:%s\n执行时间:%s\n", + summary, + task.Title, + task.Summary, + fallbackText(req.ActionDetail, "根据当前事项上下文生成最小交付物。"), + joinWorkerList(req.Inputs, "当前事项上下文"), + joinWorkerList(req.Outputs, "交付草案"), + fallbackText(connectorSummary, "未触发"), + fallbackText(aiSummary, "未触发"), + now.Format("2006-01-02 15:04"), + ) + if aiSummary != "" { + summary = aiSummary + } + if connectorSummary != "" && !strings.Contains(summary, connectorSummary) { + summary = summary + " " + connectorSummary + } + + return workerRunOutput{ + Summary: summary, + TaskStatus: status, + ArtifactTitle: artifactTitle, + ArtifactType: artifactType, + ArtifactStatus: artifactStatus, + ArtifactContent: content, + SourceRefs: append(filterWorkerParts(append([]string{}, req.Inputs...)), connectorSourceRefs...), + Timeline: []string{ + fmt.Sprintf("%s 读取事项上下文", now.Format("15:04")), + fmt.Sprintf("%s 执行动作:%s", now.Format("15:04"), req.ActionTitle), + fmt.Sprintf("%s 事项状态更新为:%s", now.Format("15:04"), status), + }, + Trace: append([]string{ + fmt.Sprintf("worker.task.load(%d)", task.ID), + fmt.Sprintf("worker.action.execute(%s)", req.ActionKey), + fmt.Sprintf("worker.artifact.create(%s)", artifactType), + }, append(connectorTrace, aiTrace...)...), + Logs: append([]string{ + fmt.Sprintf("[INFO] task %d loaded", task.ID), + fmt.Sprintf("[INFO] action %s executed", req.ActionKey), + fmt.Sprintf("[INFO] task status updated to %s", status), + }, append(connectorLogs, aiLogs...)...), + } +} + +type workerExecutionStrategy struct { + UseAI bool + ConnectorKey string +} + +func chooseWorkerStrategy(specialist model.Specialist, req workerActionReq) workerExecutionStrategy { + strategy := workerExecutionStrategy{} + actionType := strings.ToLower(strings.TrimSpace(req.ActionType)) + if actionType == "generation" || actionType == "analysis" || containsAny(req.ActionTitle, "生成", "汇总", "分析", "对比", "方案", "草案", "总结") { + strategy.UseAI = true + } + strategy.ConnectorKey = selectWorkerConnector(specialist, req) + if actionType == "coordination" && strategy.ConnectorKey == "" { + strategy.UseAI = true + } + return strategy +} + +func executeConnectorStep(specialist model.Specialist, task model.WorkerTask, req workerActionReq, connectorKey string) (string, []string, []string, []string) { + if connectorKey == "" || Cfg == nil { + return "", nil, nil, nil + } + definition, ok := connector.GetDefinition(Cfg, connectorKey) + if !ok || definition.Direction != "input" || len(definition.Objects) == 0 { + return "", nil, nil, nil + } + object := definition.Objects[0] + fieldKeys := object.DefaultFields + if len(fieldKeys) > 5 { + fieldKeys = fieldKeys[:5] + } + result, err := connector.Query(context.Background(), Cfg, connectorKey, connector.QueryRequest{ + ObjectKey: object.Key, + FieldKeys: fieldKeys, + FilterString: task.Title, + Limit: 3, + UseDemo: true, + }) + if err != nil { + return "", nil, + []string{fmt.Sprintf("connector.query(%s,%s)", connectorKey, object.Key)}, + []string{fmt.Sprintf("[WARN] connector %s query failed: %v", connectorKey, err)} + } + count, _ := result.Outputs["count"].(int) + recordsJSON, _ := json.Marshal(result.Outputs["records"]) + summary := fmt.Sprintf("已从%s读取 %d 条%s数据。", definition.Label, count, object.Label) + sourceRef := fmt.Sprintf("%s / %s", definition.Label, object.Label) + if len(recordsJSON) > 0 { + summary = summary + " 样例:" + truncateWorkerText(string(recordsJSON), 120) + } + return summary, + []string{sourceRef}, + []string{fmt.Sprintf("connector.query(%s,%s)", connectorKey, object.Key)}, + []string{fmt.Sprintf("[INFO] connector %s returned %d records", connectorKey, count)} +} + +func executeAIStep(task model.WorkerTask, specialist model.Specialist, req workerActionReq, user *model.User, enabled bool) (string, []string, []string) { + if !enabled || user == nil || Cfg == nil { + return "", nil, nil + } + if user.AiPoints <= 0 { + return "AI 点数不足,未触发智能生成。", nil, []string{"[WARN] ai execution skipped because credits are insufficient"} + } + route, err := config.GetRoute("title_gen") + if err != nil { + return "LLM 路由未配置,未触发智能生成。", nil, []string{fmt.Sprintf("[WARN] ai route missing: %v", err)} + } + contextJSON := parseWorkerContextJSON(task.ContextJSON) + knowledge := ai.Retrieve(Cfg, task.Title+" "+task.Summary+" "+specialist.InfoSources, 5) + systemPrompt := buildSystemPrompt(contextJSON, knowledge) + "\n\n当前任务:" + buildWorkerAITaskPrompt(task, specialist, req) + messages := []ai.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: buildWorkerUserPrompt(task, req)}, + } + + start := time.Now() + result, usedRoute, err := ai.GenerateFullWithFallback(route, messages) + if err != nil { + ai.LogCall(ai.LogEntry{ + UserID: user.ID, + Capability: ai.CapabilityTextGen, + Provider: route.Provider, + RouteID: route.RouteID, + Model: route.Model, + Success: false, + ErrorMessage: err.Error(), + LatencyMs: int(time.Since(start).Milliseconds()), + }) + return "AI 生成失败,已回退到规则结果。", []string{"ai.generate(worker_action)"}, []string{fmt.Sprintf("[WARN] ai generation failed: %v", err)} + } + ai.LogCall(ai.LogEntry{ + UserID: user.ID, + Capability: ai.CapabilityTextGen, + Provider: usedRoute.Provider, + RouteID: usedRoute.RouteID, + Model: usedRoute.Model, + Success: true, + TokensInput: result.Usage.PromptTokens, + TokensOutput: result.Usage.CompletionTokens, + LatencyMs: int(time.Since(start).Milliseconds()), + }) + return truncateWorkerText(strings.TrimSpace(result.Content), 240), + []string{"ai.generate(worker_action)"}, + []string{"[INFO] ai generation completed"} +} + +func buildBottomPanel(runs []model.WorkerRun) gin.H { + panel := gin.H{ + "timeline": []string{}, + "replay": []string{}, + "trace": []string{}, + "logs": []string{}, + } + for _, run := range runs { + var output workerRunOutput + _ = json.Unmarshal([]byte(run.OutputJSON), &output) + var logs []string + _ = json.Unmarshal([]byte(run.LogsJSON), &logs) + + panel["timeline"] = appendStringSlice(panel["timeline"], output.Timeline) + panel["replay"] = appendStringSlice(panel["replay"], []string{ + fmt.Sprintf("%s · %s · %s", run.StartedAt.Format("01-02 15:04"), run.ActionTitle, output.Summary), + }) + panel["trace"] = appendStringSlice(panel["trace"], output.Trace) + panel["logs"] = appendStringSlice(panel["logs"], logs) + } + return panel +} + +func appendStringSlice(current any, values []string) []string { + base, _ := current.([]string) + return append(base, values...) +} + +func defaultWorkerTaskTitle(specialist model.Specialist) string { + if strings.TrimSpace(specialist.Summary) != "" { + return strings.TrimSpace(specialist.Summary) + } + return specialist.Label + "默认事项" +} + +func defaultWorkerTaskSummary(specialist model.Specialist) string { + parts := []string{strings.TrimSpace(specialist.Summary), strings.TrimSpace(specialist.InfoSources), strings.TrimSpace(specialist.GeneratedSkills)} + joined := strings.Join(filterWorkerParts(parts), " · ") + if joined != "" { + return joined + } + return "基于当前专员定义生成的默认事项。" +} + +func defaultWorkerTaskStatus(specialist model.Specialist) string { + for _, value := range []string{specialist.Stage, specialist.WorkStatus} { + value = normalizeWorkerTaskStatus(value) + if value != "" { + return value + } + } + return workerTaskStatusPending +} + +func normalizeWorkerActionKey(key, title string) string { + key = strings.TrimSpace(key) + if key != "" { + return key + } + title = strings.TrimSpace(title) + replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", "·", "-", "_", "-") + title = replacer.Replace(title) + title = strings.ToLower(title) + title = strings.ReplaceAll(title, "--", "-") + if title == "" { + return "action" + } + return title +} + +func bootstrapOwner(user *model.User) string { + if user != nil && strings.TrimSpace(user.FullName) != "" { + return strings.TrimSpace(user.FullName) + } + return "待指派" +} + +func joinWorkerList(items []string, fallback string) string { + trimmed := filterWorkerParts(items) + if len(trimmed) == 0 { + return fallback + } + return strings.Join(trimmed, " / ") +} + +func filterWorkerParts(items []string) []string { + result := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } + return result +} + +func fallbackText(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} + +func containsAny(value string, items ...string) bool { + value = strings.TrimSpace(value) + for _, item := range items { + if strings.Contains(value, item) { + return true + } + } + return false +} + +func selectWorkerConnector(specialist model.Specialist, req workerActionReq) string { + type inputRecord struct { + InputType string `json:"input_type"` + ConnectorName string `json:"connector_name"` + } + var records []inputRecord + if err := json.Unmarshal([]byte(firstNonEmptyWorkerJSON(specialist.InputsRecordsJSON, "[]")), &records); err == nil { + for _, item := range records { + key := normalizeConnectorKey(item.ConnectorName) + if item.InputType == "connector" && key != "" && key != "workspace_context" { + if _, ok := connector.GetDefinition(Cfg, key); ok { + return key + } + } + } + } + + searchText := strings.Join([]string{ + specialist.ConnectorScope, + specialist.InfoSources, + req.ActionTitle, + req.ActionDetail, + joinWorkerList(req.Inputs, ""), + }, " ") + aliases := map[string]string{ + "邮件": "mail163_input", + "邮箱": "mail163_input", + "履约": "wecom_sheet_input", + "表格": "feishu_bitable_input", + "表单": "feishu_bitable_input", + "仓": "wms_input", + "库存": "wms_input", + "工单": "mes_input", + "产线": "mes_input", + "生产": "mes_input", + } + for keyword, key := range aliases { + if strings.Contains(searchText, keyword) { + return key + } + } + return "" +} + +func parseWorkerContextJSON(value string) map[string]any { + if strings.TrimSpace(value) == "" { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal([]byte(value), &out); err != nil { + return map[string]any{} + } + return out +} + +func buildWorkerAITaskPrompt(task model.WorkerTask, specialist model.Specialist, req workerActionReq) string { + return strings.TrimSpace(fmt.Sprintf( + "你正在为数字员工平台执行专员动作。\n专员:%s\n事项:%s\n事项摘要:%s\n动作:%s\n动作类型:%s\n预期输出:%s\n请输出一段可以直接作为事项交付草案的中文结果,要求简洁、业务化、避免空话。", + specialist.Label, + task.Title, + task.Summary, + req.ActionTitle, + req.ActionType, + joinWorkerList(req.Outputs, "交付草案"), + )) +} + +func buildWorkerUserPrompt(task model.WorkerTask, req workerActionReq) string { + return strings.TrimSpace(fmt.Sprintf( + "请基于当前事项上下文执行动作。\n事项标题:%s\n当前状态:%s\n动作说明:%s\n输入信源:%s\n预期输出:%s", + task.Title, + task.Status, + fallbackText(req.ActionDetail, "根据当前事项生成结果"), + joinWorkerList(req.Inputs, "当前事项上下文"), + joinWorkerList(req.Outputs, "交付草案"), + )) +} + +func truncateWorkerText(value string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= max { + return value + } + return string(runes[:max]) + "..." +} + +func normalizeConnectorKey(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "" + } + replacer := strings.NewReplacer(" ", "_", "-", "_", "/", "_", "\\", "_") + return replacer.Replace(value) +} + +func firstNonEmptyWorkerJSON(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} + +const ( + workerTaskStatusPending = "待处理" + workerTaskStatusInProgress = "进行中" + workerTaskStatusDraft = "草案已生成" + workerTaskStatusPendingReview = "待确认" + workerTaskStatusCompleted = "已完成" + workerTaskStatusArchived = "已归档" + + workerArtifactStatusDraft = "draft" + workerArtifactStatusReady = "ready" + workerArtifactStatusApproved = "approved" + workerArtifactStatusPublished = "published" +) + +func normalizeWorkerTaskStatus(value string) string { + value = strings.TrimSpace(value) + switch { + case value == "", strings.Contains(value, "待处理"), strings.Contains(value, "待开始"): + return workerTaskStatusPending + case strings.Contains(value, "进行"), strings.Contains(value, "执行"), strings.Contains(value, "处理中"): + return workerTaskStatusInProgress + case strings.Contains(value, "草案"), strings.Contains(value, "方案生成"), strings.Contains(value, "人工复核"): + return workerTaskStatusDraft + case strings.Contains(value, "确认"), strings.Contains(value, "审批"), strings.Contains(value, "评审"): + return workerTaskStatusPendingReview + case strings.Contains(value, "完成"): + return workerTaskStatusCompleted + case strings.Contains(value, "归档"), strings.Contains(value, "发布"): + return workerTaskStatusArchived + default: + return value + } +} + +func isAllowedWorkerTaskStatus(value string) bool { + switch value { + case workerTaskStatusPending, workerTaskStatusInProgress, workerTaskStatusDraft, workerTaskStatusPendingReview, workerTaskStatusCompleted, workerTaskStatusArchived: + return true + default: + return false + } +} + +func normalizeWorkerArtifactStatus(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + switch value { + case workerArtifactStatusDraft, workerArtifactStatusReady, workerArtifactStatusApproved, workerArtifactStatusPublished: + return value + case "pending_review": + return workerArtifactStatusReady + case "done": + return workerArtifactStatusApproved + default: + return value + } +} + +func canTransitionWorkerArtifactStatus(current, next string) bool { + current = normalizeWorkerArtifactStatus(current) + next = normalizeWorkerArtifactStatus(next) + if current == next { + return true + } + switch current { + case workerArtifactStatusDraft: + return next == workerArtifactStatusReady + case workerArtifactStatusReady: + return next == workerArtifactStatusApproved || next == workerArtifactStatusDraft + case workerArtifactStatusApproved: + return next == workerArtifactStatusPublished || next == workerArtifactStatusDraft + case workerArtifactStatusPublished: + return false + default: + return false + } +} + +func taskStatusFromArtifactStatus(status string) string { + switch normalizeWorkerArtifactStatus(status) { + case workerArtifactStatusDraft: + return workerTaskStatusDraft + case workerArtifactStatusReady: + return workerTaskStatusPendingReview + case workerArtifactStatusApproved: + return workerTaskStatusCompleted + case workerArtifactStatusPublished: + return workerTaskStatusArchived + default: + return workerTaskStatusInProgress + } +} + +func buildArtifactStatusSummary(artifact model.WorkerArtifact, remark string) string { + base := fmt.Sprintf("交付物《%s》状态已更新为%s。", artifact.Title, taskStatusFromArtifactStatus(artifact.Status)) + if remark != "" { + return base + " 备注:" + remark + } + return base +} + +func buildWorkerTaskLink(specialistKey string) string { + var specialist model.Specialist + if err := store.DB.Where("key = ?", strings.TrimSpace(specialistKey)).First(&specialist).Error; err == nil { + return specialist.Route + } + return "" +} + +func notifyOnArtifactStatusChange(task model.WorkerTask, artifact model.WorkerArtifact) { + link := buildWorkerTaskLink(task.SpecialistKey) + switch normalizeWorkerArtifactStatus(artifact.Status) { + case workerArtifactStatusReady: + notifyAdmins( + "worker_artifact_review", + fmt.Sprintf("待确认交付物:%s", artifact.Title), + fmt.Sprintf("事项《%s》产出了待确认交付物,请及时审核。", task.Title), + link, + ) + case workerArtifactStatusApproved: + notifyUsersByOwner( + task.Owner, + "worker_artifact_approved", + fmt.Sprintf("交付物已确认:%s", artifact.Title), + fmt.Sprintf("事项《%s》的交付物已经确认通过。", task.Title), + link, + ) + case workerArtifactStatusPublished: + notifyUsersByOwner( + task.Owner, + "worker_artifact_published", + fmt.Sprintf("交付物已发布:%s", artifact.Title), + fmt.Sprintf("事项《%s》的交付物已进入发布/归档状态。", task.Title), + link, + ) + } +} diff --git a/eai_ap_app/backend-go/internal/config/config.go b/eai_ap_app/backend-go/internal/config/config.go index a661bec..94db867 100644 --- a/eai_ap_app/backend-go/internal/config/config.go +++ b/eai_ap_app/backend-go/internal/config/config.go @@ -19,6 +19,9 @@ type Config struct { LLMModel string EmbedModel string + KnowledgeServiceURL string + KnowledgeIndexDir string + KBDataDir string KnowledgeSourceDir string @@ -52,6 +55,9 @@ func Load() *Config { LLMModel: getenv("LLM_MODEL", "qwen2.5:7b"), EmbedModel: getenv("EMBED_MODEL", "bge-m3"), + KnowledgeServiceURL: getenv("KNOWLEDGE_SERVICE_URL", "http://127.0.0.1:10233"), + KnowledgeIndexDir: getenv("KNOWLEDGE_INDEX_DIR", filepath.Join(baseDir, "data", "faiss")), + KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")), KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(baseDir, "knowledge_source")), diff --git a/eai_ap_app/backend-go/internal/model/knowledge_chunk.go b/eai_ap_app/backend-go/internal/model/knowledge_chunk.go index cd38a2b..fc37321 100644 --- a/eai_ap_app/backend-go/internal/model/knowledge_chunk.go +++ b/eai_ap_app/backend-go/internal/model/knowledge_chunk.go @@ -5,10 +5,11 @@ import "time" // KnowledgeChunk 知识块表(AI 检索最小单元) type KnowledgeChunk struct { ID uint `gorm:"primaryKey" json:"id"` - MediaFileID *uint `gorm:"index" json:"media_file_id"` // 与 knowledge_source_id 二选一 - KnowledgeSourceID *uint `gorm:"index" json:"knowledge_source_id"` // 与 media_file_id 二选一 + MediaFileID *uint `gorm:"index" json:"media_file_id"` // 与 knowledge_source_id 二选一 + KnowledgeSourceID *uint `gorm:"index" json:"knowledge_source_id"` // 与 media_file_id 二选一 SourceType string `gorm:"size:32;not null" json:"source_type"` // pdf/doc/md SourceID string `gorm:"size:64" json:"source_id"` + KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"` ChunkIndex int `gorm:"not null" json:"chunk_index"` Content string `gorm:"type:text;not null" json:"content"` CreatedAt time.Time `json:"created_at"` diff --git a/eai_ap_app/backend-go/internal/model/knowledge_faq.go b/eai_ap_app/backend-go/internal/model/knowledge_faq.go new file mode 100644 index 0000000..bc81e2d --- /dev/null +++ b/eai_ap_app/backend-go/internal/model/knowledge_faq.go @@ -0,0 +1,20 @@ +package model + +import "time" + +// KnowledgeFAQ FAQ 标准问答表 +type KnowledgeFAQ struct { + ID uint `gorm:"primaryKey" json:"id"` + KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"` + Question string `gorm:"size:512;not null" json:"question"` + Answer string `gorm:"type:text;not null" json:"answer"` + SimilarQuestions string `gorm:"type:text" json:"similar_questions"` + Keywords string `gorm:"size:512" json:"keywords"` + Status string `gorm:"size:16;not null;default:active;index" json:"status"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + HitCount int `gorm:"not null;default:0" json:"hit_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (KnowledgeFAQ) TableName() string { return "knowledge_faq" } diff --git a/eai_ap_app/backend-go/internal/model/knowledge_source.go b/eai_ap_app/backend-go/internal/model/knowledge_source.go index 7c5e552..5085474 100644 --- a/eai_ap_app/backend-go/internal/model/knowledge_source.go +++ b/eai_ap_app/backend-go/internal/model/knowledge_source.go @@ -4,19 +4,20 @@ import "time" // KnowledgeSource 结构化知识源表 type KnowledgeSource struct { - ID uint `gorm:"primaryKey" json:"id"` - Title string `gorm:"size:256;not null" json:"title"` - FilePath string `gorm:"size:512;uniqueIndex;not null" json:"file_path"` - Category string `gorm:"size:64;not null;index" json:"category"` // general/capital_consulting/qualification_counseling/ai_consulting/ai_tools_platform - Domain string `gorm:"size:16;not null;default:product" json:"domain"` // company / product / sales - SourceVersion string `gorm:"size:32;not null" json:"source_version"` - AuditStatus string `gorm:"size:16;not null;default:pending;index" json:"audit_status"` // pending / approved / rejected - AuditBy *uint `json:"audit_by"` - AuditAt *time.Time `json:"audit_at"` - RejectReason string `gorm:"size:512" json:"reject_reason"` - Ingested bool `gorm:"not null;default:false" json:"ingested"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:256;not null" json:"title"` + FilePath string `gorm:"size:512;uniqueIndex;not null" json:"file_path"` + Category string `gorm:"size:64;not null;index" json:"category"` // general/capital_consulting/qualification_counseling/ai_consulting/ai_tools_platform + Domain string `gorm:"size:16;not null;default:product" json:"domain"` // company / product / sales + SourceVersion string `gorm:"size:32;not null" json:"source_version"` + AuditStatus string `gorm:"size:16;not null;default:pending;index" json:"audit_status"` // pending / approved / rejected + KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"` + AuditBy *uint `json:"audit_by"` + AuditAt *time.Time `json:"audit_at"` + RejectReason string `gorm:"size:512" json:"reject_reason"` + Ingested bool `gorm:"not null;default:false" json:"ingested"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (KnowledgeSource) TableName() string { return "knowledge_source" } diff --git a/eai_ap_app/backend-go/internal/model/knowledge_space.go b/eai_ap_app/backend-go/internal/model/knowledge_space.go new file mode 100644 index 0000000..ab471ff --- /dev/null +++ b/eai_ap_app/backend-go/internal/model/knowledge_space.go @@ -0,0 +1,19 @@ +package model + +import "time" + +// KnowledgeSpace 知识空间定义,供知识库应用与知识库管理共享。 +type KnowledgeSpace struct { + ID uint `gorm:"primaryKey" json:"id"` + Key string `gorm:"size:64;uniqueIndex;not null" json:"key"` + Name string `gorm:"size:128;not null" json:"name"` + Description string `gorm:"size:512" json:"description"` + Scope string `gorm:"size:32;not null;default:general" json:"scope"` + Status string `gorm:"size:16;not null;default:active;index" json:"status"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + IsDefault bool `gorm:"not null;default:false" json:"is_default"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (KnowledgeSpace) TableName() string { return "knowledge_space" } diff --git a/eai_ap_app/backend-go/internal/model/media_file.go b/eai_ap_app/backend-go/internal/model/media_file.go index e6ff641..41e5775 100644 --- a/eai_ap_app/backend-go/internal/model/media_file.go +++ b/eai_ap_app/backend-go/internal/model/media_file.go @@ -4,23 +4,24 @@ import "time" // MediaFile 素材文件表 type MediaFile struct { - ID uint `gorm:"primaryKey" json:"id"` - Filename string `gorm:"size:256;not null" json:"filename"` - StoredName string `gorm:"size:64;not null" json:"stored_name"` - StoredPath string `gorm:"size:512;not null" json:"stored_path"` - FileExt string `gorm:"size:16;not null" json:"file_ext"` - FileSize int64 `gorm:"not null" json:"file_size"` - Status string `gorm:"size:16;not null;default:pending;index" json:"status"` // pending / approved / rejected - Source string `gorm:"size:16;not null" json:"source"` // employee / admin - SubmitterID uint `gorm:"not null;index" json:"submitter_id"` - BindType string `gorm:"size:16;not null;default:none" json:"bind_type"` // company / product / course / none - BindID *uint `gorm:"index" json:"bind_id"` - Remark string `gorm:"size:512" json:"remark"` // 提交备注(员工素材建议) - RejectReason string `gorm:"size:512" json:"reject_reason"` - AuditBy *uint `json:"audit_by"` - AuditAt *time.Time `json:"audit_at"` - Extracted bool `gorm:"not null;default:false;index" json:"extracted"` - CreatedAt time.Time `json:"created_at"` + ID uint `gorm:"primaryKey" json:"id"` + Filename string `gorm:"size:256;not null" json:"filename"` + StoredName string `gorm:"size:64;not null" json:"stored_name"` + StoredPath string `gorm:"size:512;not null" json:"stored_path"` + FileExt string `gorm:"size:16;not null" json:"file_ext"` + FileSize int64 `gorm:"not null" json:"file_size"` + Status string `gorm:"size:16;not null;default:pending;index" json:"status"` // pending / approved / rejected + Source string `gorm:"size:16;not null" json:"source"` // employee / admin + SubmitterID uint `gorm:"not null;index" json:"submitter_id"` + BindType string `gorm:"size:16;not null;default:none" json:"bind_type"` // company / product / course / none + BindID *uint `gorm:"index" json:"bind_id"` + Remark string `gorm:"size:512" json:"remark"` // 提交备注(员工素材建议) + RejectReason string `gorm:"size:512" json:"reject_reason"` + AuditBy *uint `json:"audit_by"` + AuditAt *time.Time `json:"audit_at"` + KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"` + Extracted bool `gorm:"not null;default:false;index" json:"extracted"` + CreatedAt time.Time `json:"created_at"` } func (MediaFile) TableName() string { return "media_file" } diff --git a/eai_ap_app/backend-go/internal/model/worker_artifact.go b/eai_ap_app/backend-go/internal/model/worker_artifact.go new file mode 100644 index 0000000..7b30c0f --- /dev/null +++ b/eai_ap_app/backend-go/internal/model/worker_artifact.go @@ -0,0 +1,21 @@ +package model + +import "time" + +// WorkerArtifact 专员交付物 +type WorkerArtifact struct { + ID uint `gorm:"primaryKey" json:"id"` + TaskID uint `gorm:"not null;index" json:"task_id"` + SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"` + Title string `gorm:"size:128;not null" json:"title"` + ArtifactType string `gorm:"size:32;not null;default:text" json:"artifact_type"` + Status string `gorm:"size:32;not null;default:draft;index" json:"status"` + ContentText string `gorm:"type:text" json:"content_text"` + ContentJSON string `gorm:"type:text" json:"content_json"` + SourceRefsJSON string `gorm:"type:text" json:"source_refs_json"` + CreatedByRunID *uint `gorm:"index" json:"created_by_run_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (WorkerArtifact) TableName() string { return "worker_artifact" } diff --git a/eai_ap_app/backend-go/internal/model/worker_run.go b/eai_ap_app/backend-go/internal/model/worker_run.go new file mode 100644 index 0000000..8b4f3e0 --- /dev/null +++ b/eai_ap_app/backend-go/internal/model/worker_run.go @@ -0,0 +1,23 @@ +package model + +import "time" + +// WorkerRun 专员动作执行记录 +type WorkerRun struct { + ID uint `gorm:"primaryKey" json:"id"` + TaskID uint `gorm:"not null;index" json:"task_id"` + SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"` + ActionKey string `gorm:"size:64;not null;index" json:"action_key"` + ActionTitle string `gorm:"size:128;not null" json:"action_title"` + ActionType string `gorm:"size:32;default:''" json:"action_type"` + Status string `gorm:"size:16;not null;default:done;index" json:"status"` + InputJSON string `gorm:"type:text" json:"input_json"` + OutputJSON string `gorm:"type:text" json:"output_json"` + LogsJSON string `gorm:"type:text" json:"logs_json"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (WorkerRun) TableName() string { return "worker_run" } diff --git a/eai_ap_app/backend-go/internal/model/worker_task.go b/eai_ap_app/backend-go/internal/model/worker_task.go new file mode 100644 index 0000000..1f483e9 --- /dev/null +++ b/eai_ap_app/backend-go/internal/model/worker_task.go @@ -0,0 +1,24 @@ +package model + +import "time" + +// WorkerTask 专员事项/任务 +type WorkerTask struct { + ID uint `gorm:"primaryKey" json:"id"` + SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"` + Title string `gorm:"size:128;not null" json:"title"` + Summary string `gorm:"type:text" json:"summary"` + Owner string `gorm:"size:64;default:''" json:"owner"` + Priority string `gorm:"size:16;not null;default:P2" json:"priority"` + Status string `gorm:"size:32;not null;default:待处理;index" json:"status"` + ContextJSON string `gorm:"type:text" json:"context_json"` + CurrentRunID *uint `gorm:"index" json:"current_run_id"` + CurrentResult string `gorm:"type:text" json:"current_result"` + DueAt *time.Time `json:"due_at"` + CreatedBy *uint `gorm:"index" json:"created_by"` + LastTriggeredAt *time.Time `json:"last_triggered_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (WorkerTask) TableName() string { return "worker_task" } diff --git a/eai_ap_app/backend-go/internal/store/db.go b/eai_ap_app/backend-go/internal/store/db.go index 0a2cfdf..2acf063 100644 --- a/eai_ap_app/backend-go/internal/store/db.go +++ b/eai_ap_app/backend-go/internal/store/db.go @@ -35,6 +35,8 @@ func Init(dbPath string) (*gorm.DB, error) { &model.Product{}, &model.Course{}, &model.MediaFile{}, + &model.KnowledgeSpace{}, + &model.KnowledgeFAQ{}, &model.KnowledgeChunk{}, &model.Question{}, &model.ExamPaper{}, @@ -53,6 +55,9 @@ func Init(dbPath string) (*gorm.DB, error) { &model.Notification{}, &model.StudyNote{}, &model.Specialist{}, + &model.WorkerTask{}, + &model.WorkerArtifact{}, + &model.WorkerRun{}, ); err != nil { return nil, err } diff --git a/eai_ap_app/backend-go/internal/store/seed.go b/eai_ap_app/backend-go/internal/store/seed.go index cbcb4f9..4d90e4c 100644 --- a/eai_ap_app/backend-go/internal/store/seed.go +++ b/eai_ap_app/backend-go/internal/store/seed.go @@ -1,11 +1,13 @@ package store import ( - "encoding/json" - "log" - "os" - "path/filepath" - "strings" + "encoding/json" + "io" + "log" + "os" + "path/filepath" + "runtime" + "strings" "eaisalestrain/backend/internal/auth" "eaisalestrain/backend/internal/config" @@ -633,7 +635,40 @@ func ensureMediaLink(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } - return os.Symlink(absSrc, dst) + if err := os.Symlink(absSrc, dst); err == nil { + return nil + } else if shouldCopySeedMedia(err) { + return copySeedMedia(absSrc, dst) + } else { + return err + } +} + +func shouldCopySeedMedia(err error) bool { + if err == nil { + return false + } + // Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。 + return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege") +} + +func copySeedMedia(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() } func firstExistingPath(paths ...string) string { diff --git a/eai_ap_app/backend-go/knowledge_source/11_规则制度库_审批制度样本.md b/eai_ap_app/backend-go/knowledge_source/11_规则制度库_审批制度样本.md new file mode 100644 index 0000000..9cb8b65 --- /dev/null +++ b/eai_ap_app/backend-go/knowledge_source/11_规则制度库_审批制度样本.md @@ -0,0 +1,55 @@ +--- +knowledge_source: 规则制度库审批制度样本 V1.0 +version: V1.0 +category: policy +domain: governance +knowledge_space_key: policy +audit_status: pending +--- + +# 规则制度库:事项审批与资料提交流程 + +## 结构化产品数据 + +> 本文件为制度规则样本,不写入产品表,仅生成知识切片与题目,供 04 知识库应用做制度问答演示。 + +## AI 检索知识 + +### 审批适用范围 +涉及合同盖章、付款申请、客户报价确认、对外正式承诺、知识库正式发布的事项,必须先完成系统审批,未经审批不得直接对外执行。 + +### 提交流程要求 +提交审批时,申请人必须填写事项名称、业务背景、影响范围、期望完成时间,并上传支撑材料。缺少业务背景或附件不全的申请,管理员可以直接退回并要求补充。 + +### 审批时效要求 +普通事项应在 1 个工作日内完成审批;涉及合同、报价、付款与对外承诺的高风险事项,应优先处理并在 4 小时内给出审批意见。 + +### 审批结论要求 +审批结论分为通过、退回补充、驳回三种。通过后方可进入正式执行;退回补充后需重新提交;驳回代表当前事项不予继续推进。 + +### 知识库发布要求 +知识库文档、FAQ 与切片内容在正式对员工开放前,必须由管理员完成审批。未审批内容只能停留在待审核状态,不得作为正式知识来源对外回答。 + +## 考试题目 + +### Q1 +- type: judge +- domain: governance +- stem: 涉及合同盖章与付款申请的事项,可以不经过审批直接执行。 +- answer: [false] +- explanation: 合同盖章与付款申请属于必须先审批的高风险事项。 + +### Q2 +- type: single +- domain: governance +- stem: 审批结论包含以下哪三种? +- options: ["通过、退回补充、驳回", "通过、存档、结束", "补录、审批、归档", "通过、修改、删除"] +- answer: [0] +- explanation: 审批结论标准为通过、退回补充、驳回。 + +### Q3 +- type: judge +- domain: governance +- stem: 未完成管理员审批的知识库内容,可以直接作为正式知识来源对外回答。 +- answer: [false] +- explanation: 未审批内容不能作为正式知识来源对外回答。 diff --git a/eai_ap_app/backend-go/knowledge_source/12_通用知识库_日常协同样本.md b/eai_ap_app/backend-go/knowledge_source/12_通用知识库_日常协同样本.md new file mode 100644 index 0000000..076fb9b --- /dev/null +++ b/eai_ap_app/backend-go/knowledge_source/12_通用知识库_日常协同样本.md @@ -0,0 +1,48 @@ +--- +knowledge_source: 通用知识库日常协同样本 V1.0 +version: V1.0 +category: general +domain: collaboration +knowledge_space_key: general +audit_status: pending +--- + +# 通用知识库:日常协同与交付规范 + +## 结构化产品数据 + +> 本文件用于知识库检索演示,不写入产品表。 + +## AI 检索知识 + +### 日报提交要求 +员工应在每个工作日 18:00 前提交当日工作日报,至少包含今日完成事项、当前阻塞问题、明日计划三项内容。连续两次未提交日报的,由直属管理员进行提醒。 + +### 周会材料准备要求 +部门周会材料应在会议开始前 2 小时上传到共享空间,内容至少包含上周进展、本周重点、风险事项和需要协同支持的问题。 + +### 客户沟通记录要求 +涉及正式业务推进的客户沟通,应在沟通结束后 30 分钟内补录到系统,记录沟通对象、核心结论、后续动作与负责人。 + +### 交付物命名规范 +正式交付物命名格式统一为“项目简称-交付物类型-版本号-日期”,便于后续检索、审批与归档。 + +### 高风险事项升级要求 +若事项存在客户投诉、合同争议、付款异常或超期未处理情况,负责人需在 1 小时内同步管理员,并在系统中标记为高风险。 + +## 考试题目 + +### Q1 +- type: judge +- domain: collaboration +- stem: 日报至少要包含今日完成事项、当前阻塞问题、明日计划。 +- answer: [true] +- explanation: 这三项是日报提交的基础要求。 + +### Q2 +- type: single +- domain: collaboration +- stem: 正式交付物命名格式应包含哪些内容? +- options: ["项目简称-交付物类型-版本号-日期", "项目简称-日期", "交付物类型-负责人", "客户名称-版本号"] +- answer: [0] +- explanation: 正式交付物命名格式统一为项目简称-交付物类型-版本号-日期。 diff --git a/eai_ap_app/backend-go/knowledge_source/13_产品知识库_产品卖点样本.md b/eai_ap_app/backend-go/knowledge_source/13_产品知识库_产品卖点样本.md new file mode 100644 index 0000000..3e62f70 --- /dev/null +++ b/eai_ap_app/backend-go/knowledge_source/13_产品知识库_产品卖点样本.md @@ -0,0 +1,59 @@ +--- +knowledge_source: 产品知识库产品卖点样本 V1.0 +version: V1.0 +category: product +domain: sales +knowledge_space_key: product +audit_status: pending +--- + +# 产品知识库:核心产品卖点与销售话术 + +## 结构化产品数据 + +### 产品A +- code: DEMO-PRO-01 +- name: 智能训练营陪跑服务 +- category: 服务产品 +- tags: 培训,陪跑,顾问式交付 +- description: 面向企业客户提供训练营设计、作业点评、过程复盘和陪跑式辅导。 +- pricing: 标准版 3 万 / 期,高配版 5 万 / 期 +- commission_recommend: 12% +- commission_negotiate: 8% +- public_course_bonus: 底价内按回款结算,超额部分归乙方 +- version_risk: 高配版需要提前锁定顾问排期 +- report_rules: 成交后需同步客户阶段目标和交付节奏 + +## AI 检索知识 + +### 智能训练营核心卖点 +智能训练营强调“课程学习 + 实战作业 + 顾问点评 + 复盘陪跑”的闭环,不只是交付课程,而是帮助客户把学习结果转成真实业务动作。 + +### 公开课与训练营区别 +公开课适合快速普及认知,交付标准化;训练营更适合需要过程陪跑、结果产出的客户,投入更高但业务转化更强。 + +### 销售切入建议 +当客户表达“学了很多但落不了地”“团队不知道怎么推进”时,优先推荐训练营或陪跑型产品,而不是单次公开课。 + +### 佣金说明口径 +标准产品优先执行推荐佣金比例;当客户价格需要谈判时,可在谈判佣金区间内申请调整,并在系统中补充谈判原因。 + +### 演示话术示例 +可以对客户说:我们不是只交付一套课件,而是把课程、作业、复盘和顾问陪跑一起交付,帮助团队真正把方法用起来。 + +## 考试题目 + +### Q1 +- type: single +- domain: sales +- stem: 训练营更适合哪类客户? +- options: ["需要过程陪跑和结果产出的客户", "只想快速了解概念的客户", "只关注价格最低的客户", "没有明确目标的客户"] +- answer: [0] +- explanation: 训练营适合需要过程陪跑、结果产出的客户。 + +### Q2 +- type: judge +- domain: sales +- stem: 公开课与训练营的交付方式完全相同。 +- answer: [false] +- explanation: 公开课更标准化,训练营强调陪跑和结果产出。 diff --git a/eai_ap_app/backend-go/knowledge_source/14_培训资料库_学习运营样本.md b/eai_ap_app/backend-go/knowledge_source/14_培训资料库_学习运营样本.md new file mode 100644 index 0000000..f46766d --- /dev/null +++ b/eai_ap_app/backend-go/knowledge_source/14_培训资料库_学习运营样本.md @@ -0,0 +1,48 @@ +--- +knowledge_source: 培训资料库学习运营样本 V1.0 +version: V1.0 +category: training +domain: learning +knowledge_space_key: training +audit_status: pending +--- + +# 培训资料库:学习运营与训练营执行样本 + +## 结构化产品数据 + +> 本文件用于培训资料检索演示,不写入产品表。 + +## AI 检索知识 + +### 开营前准备 +训练营开营前需完成学员名单确认、群内通知、课表发布、预习资料发放和讲师试讲检查。以上事项应在开营前 1 个工作日内全部完成。 + +### 作业点评要求 +训练营作业点评应在学员提交后 24 小时内完成,并明确指出亮点、问题和下一步改进建议,避免只给结论不给方法。 + +### 学习完成率跟进 +若某一期训练营学习完成率低于 80%,运营负责人需在周复盘中说明原因,并提出补课、提醒、督学等改进措施。 + +### 优秀案例沉淀要求 +学员在训练营中形成的高质量案例,应在结营后 3 个工作日内整理成标准案例卡片,沉淀到培训资料库中。 + +### 复盘输出要求 +每期训练营结束后应输出一份复盘报告,至少包含参与情况、完成率、优秀案例、常见问题和下一期优化建议。 + +## 考试题目 + +### Q1 +- type: judge +- domain: learning +- stem: 作业点评只要告知结果即可,不需要给改进建议。 +- answer: [false] +- explanation: 作业点评应指出亮点、问题和下一步改进建议。 + +### Q2 +- type: single +- domain: learning +- stem: 若学习完成率低于 80%,负责人需要做什么? +- options: ["在周复盘中说明原因并提出改进措施", "不处理,等待下一期", "只在群里提醒一次", "直接结束本期训练营"] +- answer: [0] +- explanation: 完成率偏低时需要复盘原因并提出改进措施。 diff --git a/eai_ap_app/backend-go/knowledge_source/15_规则制度库_发布合规样本.md b/eai_ap_app/backend-go/knowledge_source/15_规则制度库_发布合规样本.md new file mode 100644 index 0000000..b249540 --- /dev/null +++ b/eai_ap_app/backend-go/knowledge_source/15_规则制度库_发布合规样本.md @@ -0,0 +1,48 @@ +--- +knowledge_source: 规则制度库发布合规样本 V1.0 +version: V1.0 +category: policy +domain: governance +knowledge_space_key: policy +audit_status: pending +--- + +# 规则制度库:知识发布与合规要求 + +## 结构化产品数据 + +> 本文件用于制度类知识检索演示,不写入产品表。 + +## AI 检索知识 + +### FAQ 发布要求 +FAQ 上线前应由管理员确认问法、答案口径、适用范围和所属知识空间。未经确认的 FAQ 不得直接对员工开放。 + +### 知识切片质量要求 +知识切片应保持语义完整,单条切片聚焦一个主题,避免把多个无关规则混在同一条内容中,影响向量召回质量。 + +### 合规审阅要求 +涉及客户承诺、价格政策、合同条款、付款条件等敏感信息时,必须由管理员或授权负责人完成审阅后方可发布。 + +### 失效知识处理要求 +当制度更新、产品下线或话术失效时,管理员需在 1 个工作日内完成 FAQ 下线、知识切片更新和历史版本标记。 + +### 展示演示要求 +用于客户演示的知识库内容,应优先使用已审批、低风险、口径统一的资料,避免展示内部草稿和未确认结论。 + +## 考试题目 + +### Q1 +- type: judge +- domain: governance +- stem: 知识切片可以把多个无关规则混在一条里,方便一次性展示。 +- answer: [false] +- explanation: 知识切片应聚焦一个主题,保持语义完整。 + +### Q2 +- type: single +- domain: governance +- stem: 演示知识库时应优先使用哪类资料? +- options: ["已审批、低风险、口径统一的资料", "内部草稿", "未确认结论", "随手记录内容"] +- answer: [0] +- explanation: 演示应优先使用已审批且口径统一的资料。 diff --git a/eai_ap_app/frontend/src/api/ai.js b/eai_ap_app/frontend/src/api/ai.js index 2989583..3478a2f 100644 --- a/eai_ap_app/frontend/src/api/ai.js +++ b/eai_ap_app/frontend/src/api/ai.js @@ -38,8 +38,32 @@ export function getSecretsStatus() { } // SSE 流式对话:后端 text/event-stream,每行 "data: {json}\n\n" -export async function streamChat(message, context, history, onChunk) { +export async function streamChat(message, context, history, handlers) { const token = localStorage.getItem('token') + const onChunk = typeof handlers === 'function' ? handlers : handlers?.onChunk + const onMeta = typeof handlers === 'function' ? null : handlers?.onMeta + const onDone = typeof handlers === 'function' ? null : handlers?.onDone + const traceId = `chat-${Date.now()}` + // #region debug-point A:frontend-request + fetch('http://127.0.0.1:7777/event', { + method: 'POST', + body: JSON.stringify({ + sessionId: 'knowledge-chat-401', + runId: 'pre-fix', + hypothesisId: 'A', + location: 'frontend/src/api/ai.js:streamChat:request', + traceId, + msg: '[DEBUG] streamChat request', + data: { + hasToken: Boolean(token), + url: '/api/ai-chat/message', + messagePreview: String(message || '').slice(0, 80), + spaceKey: context?.knowledge_space_key || '', + }, + ts: Date.now(), + }), + }).catch(() => {}) + // #endregion const resp = await fetch('/api/ai-chat/message', { method: 'POST', headers: { @@ -51,12 +75,33 @@ export async function streamChat(message, context, history, onChunk) { if (!resp.ok) { let msg = 'LLM 请求失败' + let raw = '' try { - const j = await resp.json() + raw = await resp.text() + const j = JSON.parse(raw) msg = j.message || j.error || msg } catch { /* ignore */ } + // #region debug-point A:frontend-non200 + fetch('http://127.0.0.1:7777/event', { + method: 'POST', + body: JSON.stringify({ + sessionId: 'knowledge-chat-401', + runId: 'pre-fix', + hypothesisId: 'A', + location: 'frontend/src/api/ai.js:streamChat:non200', + traceId, + msg: '[DEBUG] streamChat non-200 response', + data: { + status: resp.status, + statusText: resp.statusText, + bodyPreview: String(raw || '').slice(0, 240), + }, + ts: Date.now(), + }), + }).catch(() => {}) + // #endregion throw new Error(msg) } @@ -84,10 +129,13 @@ export async function streamChat(message, context, history, onChunk) { continue } if (obj.type === 'text') { - onChunk(obj.content) + onChunk?.(obj.content) + } else if (obj.type === 'meta') { + onMeta?.(obj) } else if (obj.type === 'error') { throw new Error(obj.message || 'LLM 请求失败') } else if (obj.type === 'done') { + onDone?.() return } } diff --git a/eai_ap_app/frontend/src/api/knowledge.js b/eai_ap_app/frontend/src/api/knowledge.js index 2a41206..df60b04 100644 --- a/eai_ap_app/frontend/src/api/knowledge.js +++ b/eai_ap_app/frontend/src/api/knowledge.js @@ -1,5 +1,45 @@ import http from './http' +export function listKnowledgeSpaces() { + return http.get('/knowledge/spaces') +} + +export function createKnowledgeSpace(data) { + return http.post('/knowledge/spaces', data) +} + +export function updateKnowledgeSpace(id, data) { + return http.put(`/knowledge/spaces/${id}`, data) +} + +export function deleteKnowledgeSpace(id) { + return http.delete(`/knowledge/spaces/${id}`) +} + +export function listKnowledgeFaqs(params) { + return http.get('/knowledge/faqs', { params }) +} + +export function createKnowledgeFaq(data) { + return http.post('/knowledge/faqs', data) +} + +export function updateKnowledgeFaq(id, data) { + return http.put(`/knowledge/faqs/${id}`, data) +} + +export function deleteKnowledgeFaq(id) { + return http.delete(`/knowledge/faqs/${id}`) +} + +export function rebuildKnowledgeIndex() { + return http.post('/knowledge/index/rebuild') +} + +export function searchKnowledge(params) { + return http.get('/knowledge/search', { params }) +} + export function knowledgeScan() { return http.post('/knowledge/scan') } diff --git a/eai_ap_app/frontend/src/api/workbench.js b/eai_ap_app/frontend/src/api/workbench.js new file mode 100644 index 0000000..9ebc369 --- /dev/null +++ b/eai_ap_app/frontend/src/api/workbench.js @@ -0,0 +1,5 @@ +import http from './http' + +export function getWorkbenchOverview() { + return http.get('/workbench/overview') +} diff --git a/eai_ap_app/frontend/src/api/worker.js b/eai_ap_app/frontend/src/api/worker.js new file mode 100644 index 0000000..b51371c --- /dev/null +++ b/eai_ap_app/frontend/src/api/worker.js @@ -0,0 +1,37 @@ +import http from './http' + +export function listWorkerTasks(params) { + return http.get('/worker/tasks', { params }) +} + +export function getWorkerTaskDetail(id) { + return http.get(`/worker/tasks/${id}`) +} + +export function updateWorkerTaskStatus(id, data) { + return http.put(`/worker/tasks/${id}/status`, data) +} + +export function createWorkerTask(data) { + return http.post('/worker/tasks', data) +} + +export function updateWorkerTask(id, data) { + return http.put(`/worker/tasks/${id}`, data) +} + +export function deleteWorkerTask(id) { + return http.delete(`/worker/tasks/${id}`) +} + +export function getWorkerArtifactDetail(id) { + return http.get(`/worker/artifacts/${id}`) +} + +export function updateWorkerArtifactStatus(id, data) { + return http.put(`/worker/artifacts/${id}/status`, data) +} + +export function executeWorkerTaskAction(id, data) { + return http.post(`/worker/tasks/${id}/actions`, data) +} diff --git a/eai_ap_app/frontend/src/config/workbench.js b/eai_ap_app/frontend/src/config/workbench.js index 3e6093f..5d418c1 100644 --- a/eai_ap_app/frontend/src/config/workbench.js +++ b/eai_ap_app/frontend/src/config/workbench.js @@ -2,8 +2,9 @@ export const topNavItems = [ { key: 'apps', label: '01 专员工作区', title: '专员工作区', route: '/home' }, { key: 'market', label: '02 专员市场', title: '专员市场', route: '/market', adminOnly: true }, { key: 'studio', label: '03 工坊', title: '工坊', route: '/studio', adminOnly: true }, - { key: 'knowledge', label: '04 知识库', title: '知识库', route: '/knowledge-hub' }, - { key: 'console', label: '05 控制台', title: '控制台', route: '/console', adminOnly: true }, + { key: 'knowledge-app', label: '04 知识库应用', title: '知识库应用', route: '/knowledge-hub' }, + { key: 'knowledge-admin', label: '05 知识库管理', title: '知识库管理', route: '/knowledge-management', adminOnly: true }, + { key: 'console', label: '06 控制台', title: '控制台', route: '/console', adminOnly: true }, ] export const businessApps = [ @@ -136,7 +137,8 @@ export const businessApps = [ ] export const knowledgeLinks = [ - { label: '资料资产', route: '/knowledge-hub', caption: 'Assets / Evidence / Rules' }, + { label: '知识库应用', route: '/knowledge-hub', caption: 'Chat / Search / Upload' }, + { label: '知识库管理', route: '/knowledge-management', caption: 'Spaces / Docs / Chunks', adminOnly: true }, { label: '产品知识', route: '/products', caption: '现有知识内容' }, { label: '销售培训', route: '/courses', caption: '课程与训练' }, { label: '公司介绍', route: '/company-train', caption: '组织基础资料' }, @@ -159,12 +161,18 @@ export const secondaryNavItems = { { label: 'DW 模板', route: '/studio' }, { label: '发布记录', route: '/studio' }, ], - knowledge: [ - { label: '资料资产', route: '/knowledge-hub' }, + 'knowledge-app': [ + { label: '对话工作台', route: '/knowledge-hub' }, { label: '产品知识', route: '/products' }, { label: '销售培训', route: '/courses' }, { label: '公司介绍', route: '/company-train' }, ], + 'knowledge-admin': [ + { label: '知识空间', route: '/knowledge-management' }, + { label: '文档管理', route: '/knowledge-management' }, + { label: '审批发布', route: '/knowledge-management' }, + { label: '质量运营', route: '/knowledge-management' }, + ], console: [ { label: '连接器健康', route: '/console' }, { label: '资源用量', route: '/console' }, @@ -598,18 +606,33 @@ const appWorkspaceData = { const genericSurfaceContext = { '/knowledge-hub': { - outputs: ['对象字典更新建议', '证据片段索引', '规则库摘要'], - artifacts: ['知识台摘要.md', '对象映射表.xlsx'], - evidence: ['资料资产 128', '证据片段 349', '规则条目 26'], - risks: ['两个对象映射待确认', '规则库缺少版本号'], - checkpoints: ['知识管理员复核'], - relatedObjects: ['Object: Contract', 'PolicyRule: 合规红线', 'Scenario: 销售培训'], - events: ['对象字典已同步', '新增 Evidence 12 条'], + outputs: ['知识问答结果', '上传文档摘要', '会话沉淀结果'], + artifacts: ['chat-session-brief.md'], + evidence: ['产品知识库', '培训资料库', '规则制度库'], + risks: ['当前引用明细仍以会话上下文展示为主'], + checkpoints: ['确认知识空间范围'], + relatedObjects: ['KnowledgeSpace', 'KnowledgeDocument', 'KnowledgeSession'], + events: ['进入知识库应用工作台', '已加载默认知识空间'], bottomPanel: { - timeline: ['09:00 知识台加载完成', '09:15 规则同步', '09:45 对象映射更新'], - replay: ['回放节点 1: LoadAssets', '回放节点 2: RebuildEvidence', '回放节点 3: UpdateObjects'], - trace: ['connector.dms.loadAssets()', 'plugin.knowledge.extractEvidence()', 'plugin.object.syncDictionary()'], - logs: ['[INFO] assets loaded', '[INFO] evidence indexed', '[WARN] object mapping pending'], + timeline: ['09:00 知识工作台加载完成', '09:15 初始化默认会话'], + replay: ['回放节点 1: OpenWorkspace', '回放节点 2: PrepareContext'], + trace: ['knowledge.chat.open()', 'knowledge.chat.prepareContext()'], + logs: ['[INFO] knowledge application ready'], + }, + }, + '/knowledge-management': { + outputs: ['知识空间概览', '文档治理面板', '审批发布队列'], + artifacts: ['knowledge-ops-overview.md'], + evidence: ['资料文档', '知识源记录', '切片统计'], + risks: ['部分知识空间仍为逻辑划分,待后续独立落库'], + checkpoints: ['管理员确认上传与审批策略'], + relatedObjects: ['KnowledgeSpace', 'KnowledgeDocument', 'KnowledgeChunk'], + events: ['进入知识库管理台', '已加载资料与审批统计'], + bottomPanel: { + timeline: ['09:10 加载文档列表', '09:30 拉取审批队列'], + replay: ['回放节点 1: LoadDocuments', '回放节点 2: LoadAuditQueue'], + trace: ['knowledge.admin.listDocuments()', 'knowledge.admin.listSources()'], + logs: ['[INFO] knowledge management ready'], }, }, '/market': { @@ -661,11 +684,12 @@ const genericSurfaceContext = { export function getWorkbenchSurface(path) { if (path === '/knowledge-hub' || path.startsWith('/products') || path.startsWith('/courses') || path.startsWith('/company-train')) { - return 'knowledge' + return 'knowledge-app' } + if (path === '/knowledge-management' || path.startsWith('/knowledge/')) return 'knowledge-admin' if (path === '/market') return 'market' if (path === '/studio') return 'studio' - if (path === '/console' || path.startsWith('/system') || path.startsWith('/knowledge/')) return 'console' + if (path === '/console' || path.startsWith('/system')) return 'console' return 'apps' } @@ -744,6 +768,8 @@ export function getWorkspaceSnapshot(path, caseId = '') { }, } } + if (path.startsWith('/knowledge/')) return genericSurfaceContext['/knowledge-management'] + if (path.startsWith('/system') || path === '/console') return genericSurfaceContext['/console'] return genericSurfaceContext[path] || genericSurfaceContext['/knowledge-hub'] } diff --git a/eai_ap_app/frontend/src/layout/MainLayout.vue b/eai_ap_app/frontend/src/layout/MainLayout.vue index 36213d7..d745887 100644 --- a/eai_ap_app/frontend/src/layout/MainLayout.vue +++ b/eai_ap_app/frontend/src/layout/MainLayout.vue @@ -144,7 +144,7 @@ 固定入口