chore: 工作台产品化进行中的改动

把工作区里其余在制品一并入库,主要是工作台产品化的推进:

  后端:新增 capability_definition / project / my_app_center / office_skill
        接口与 action_definition / skill_definition / project / user_app_center
        模型,config 加路由健康上报。
  前端:新增 frontend/src/skills(Office 技能与 workbuddy 复刻)、
        项目管理、应用中心、能力目录页,以及配套 api / store / config;
        聊天侧新增 SpecialistChip / SpecialistPanel / SkillStrip / AppChatRail
        等组件。
  清理:移除旧 views/tools 下的单页工具(已并入工作台)、_frozen 冻结组件、
        cmd/inspect_oa_debug 调试入口,以及两份调试笔记。
  其它:文档与启动脚本同步。

(这批改动与上一提交的 SY23 工作并行进行,此前已在同一工作区内交织。)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-17 21:32:35 +08:00
co-authored by Claude Code
parent 8b136d4a10
commit 16d63de4e1
180 changed files with 22283 additions and 13850 deletions
+53 -59
View File
@@ -1,6 +1,7 @@
# BE02 — 考试模块设计
> **版本:V1.1 | 参考:pj006-zhilianyuan2 exam/routes.py + exam/service.py**
> **版本:V2.0 | 框架:Go + Gin + GORM + SQLite**
> **参考:`backend-go/internal/api/exam.go`**
---
@@ -33,72 +34,65 @@ question(题库)← exam_paper(考试配置,通过 domain+question_count
## 4. 题库 API
```python
# 管理员 — 题目 CRUD
GET /api/exam/questions?domain=company&status=active # 题目列表
POST /api/exam/questions # 新增题目
PUT /api/exam/questions/{id} # 编辑题目
DELETE /api/exam/questions/{id} # 停用题目
```go
// backend-go/internal/api/exam.go
# 管理员 — 考试配置
GET /api/exam/papers # 考试配置列表
POST /api/exam/papers # 创建考试
PUT /api/exam/papers/{id} # 编辑考试
DELETE /api/exam/papers/{id} # 停用考试
// GET /api/exam/questions?domain=company&status=active # 题目列表
func ListQuestions(c *gin.Context)
// POST /api/exam/questions # 新增题目
func CreateQuestion(c *gin.Context)
// PUT /api/exam/questions/{id} # 编辑题目
func UpdateQuestion(c *gin.Context)
// DELETE /api/exam/questions/{id} # 停用题目
func DeleteQuestion(c *gin.Context)
// GET /api/exam/papers # 考试配置列表
func ListPapers(c *gin.Context)
// POST /api/exam/papers # 创建考试
func CreatePaper(c *gin.Context)
// PUT /api/exam/papers/{id} # 编辑考试
func UpdatePaper(c *gin.Context)
// DELETE /api/exam/papers/{id} # 停用考试
func DeletePaper(c *gin.Context)
```
## 5. 学员端考试 API
```python
GET /api/exam/list # 我的考试列表
GET /api/exam/cover?id={paperId} # 考试封面/说明
POST /api/exam/start # 开始考试 → 下发题目
POST /api/exam/submit # 交卷判分
GET /api/exam/record # 我的考试记录
GET /api/exam/record/{recordId} # 考试记录详情
```go
// GET /api/exam/list # 我的考试列表
func ExamList(c *gin.Context)
// GET /api/exam/cover?id={paperId} # 考试封面/说明
func ExamCover(c *gin.Context)
// POST /api/exam/start # 开始考试 → 下发题目
func ExamStart(c *gin.Context)
// POST /api/exam/submit # 交卷判分
func ExamSubmit(c *gin.Context)
// GET /api/exam/record # 我的考试记录
func ExamRecordList(c *gin.Context)
// GET /api/exam/record/{recordId} # 考试记录详情
func ExamRecordDetail(c *gin.Context)
```
## 6. 判分逻辑(参考 zhilianyuan2 _is_correct)
## 6. 判分逻辑(`exam.go` 内 `isCorrect` 函数)
```python
# services/exam_service.py
```go
// backend-go/internal/api/exam.go
def _is_correct(qtype: str, correct: list, user: any) -> bool:
"""确定性判分"""
if qtype == "multiple": # 多选题:集合相等
return sorted(correct) == sorted(user) if user else False
elif qtype == "judge": # 判断题:值相等
return str(correct).lower() == str(user).lower()
else: # 单选题:值相等
return correct == user
def grade_paper(questions: list, answers: dict) -> dict:
"""批卷:逐题比对 → 统计得分/正确数"""
correct_count = 0
total = len(questions)
score_per_question = 100 / total if total else 0
details = []
for q in questions:
user_ans = answers.get(str(q["id"]))
is_correct = _is_correct(q["type"], q["answer"], user_ans)
if is_correct:
correct_count += 1
details.append({
"question_id": q["id"],
"is_correct": is_correct,
"user_answer": user_ans,
"correct_answer": q["answer"],
})
score = round(score_per_question * correct_count)
return {
"score": score,
"correct_count": correct_count,
"wrong_count": total - correct_count,
"passed": score >= paper.pass_score,
"details": details,
func isCorrect(qtype string, correct []string, user any) bool {
switch qtype {
case "multiple": // 多选题:集合相等
return sortedEqual(correct, us)
case "judge": // 判断题:值相等
return strings.EqualFold(correct[0], us)
default: // 单选题:值相等
return correct[0] == us
}
}
func ExamSubmit(c *gin.Context) {
// 逐题比对 → 统计得分/正确数
// 简答题(essay)走 LLM 评分,其余题型确定性判分
}
```
## 7. 自测 vs 正式考区别
@@ -113,7 +107,7 @@ def grade_paper(questions: list, answers: dict) -> dict:
## 8. 考试记录设计
```json
// exam_record.detail_json 示例
// exam_record.detail_json 示例(SQLite TEXT 列)
{
"questions": [
{
@@ -133,4 +127,4 @@ def grade_paper(questions: list, answers: dict) -> dict:
## 9. 权限
- **员工:** 仅查看自己的考试记录
- **管理员:** 查看全部考试记录(`/api/system/exam-records`)
- **管理员:** 查看全部考试记录(`/api/system/exam-records`)
+278 -162
View File
@@ -1,6 +1,6 @@
# BE03 — 素材模块设计
> **版本:V1.1 | 技术:分片上传 + LibreOffice + PyMuPDF**
> **版本:V2.0 | 技术:Go + Gin + GORM + SQLite + systemd + LibreOffice + pdftotext**
---
@@ -12,6 +12,8 @@
- 文件预览
- 转换状态查询
---
## 2. 素材状态流转
```
@@ -22,212 +24,326 @@
│ │
│ └─→ 触发异步转换管线
│ │
│ ├─ 文档 → LibreOffice 转 PDF → PyMuPDF 提取
│ ├─ 文档 → LibreOffice 转 PDF → pdftotext 提取
│ │ 文本 → 切片写入 knowledge_chunk
│ └─ 视频/图片 → 仅标记预览可用
│
└─ reject → rejected(已驳回,前台不可见)
```
## 3. 上传 API
---
## 3. 数据模型
素材模块对应 `media_file` 表(`internal/model/media.go`):
```go
// internal/model/media.go
type MediaFile struct {
model.Base
UserID uint `gorm:"not null;index"`
Filename string `gorm:"size:256;not null"`
StoredPath string `gorm:"size:512;not null"`
FileExt string `gorm:"size:16;not null"` // ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg
FileSize int64 `gorm:"not null"`
BindType string `gorm:"size:32"` // company|product|course|none
BindID *uint `gorm:"index"`
Status string `gorm:"size:16;not null;default:pending;index"` // pending|approved|rejected
AuditBy *uint
AuditAt *time.Time
Extracted bool `gorm:"not null;default:false"`
RejectReason string `gorm:"size:512"`
CreatedAt time.Time
UpdatedAt time.Time
}
```
---
## 4. 上传 API
### 直传(文档 ≤ 200MB)
```python
POST /api/media/upload
Content-Type: multipart/form-data
```go
// backend-go/internal/api/media.go
func Upload(c *gin.Context) {
// POST /api/media/upload
// Content-Type: multipart/form-data
Parameters:
- file: 文件二进制
- bind_type: company | product | course | none
- bind_id: 绑定实体 ID(可选)
file, _ := c.FormFile("file")
bindType := c.PostForm("bind_type") // company | product | course | none
bindIDStr := c.PostForm("bind_id") // 可选
Response:
{
uid := middleware.GetUserID(c)
mediaID, err := mediaSvc.Upload(c, uid, file, bindType, bindIDStr)
if err != nil { c.JSON(500, web.FAIL); return }
c.JSON(200, web.OK(gin.M{
"media_id": mediaID,
"status": "pending",
"filename": file.Filename,
}))
}
```
Response (SQLite TEXT 列):
```json
{
"code": 0,
"data": {
"media_id": 1,
"status": "pending",
"filename": "原始名称.pptx"
}
}
```
### 分片上传(视频 > 100MB)
```python
# 1. 初始化
POST /api/media/upload-init
{
"filename": "training.mp4",
"file_size": 524288000,
"bind_type": "course",
"bind_id": 1
}
Response: { "upload_id": "uuid", "chunk_size": 5242880, "chunk_count": 100 }
```go
// POST /api/media/upload-init
// → { "upload_id": "uuid", "chunk_size": 5242880, "chunk_count": 100 }
# 2. 上传分片(循环调用)
POST /api/media/upload-chunk
Content-Type: multipart/form-data
{
"upload_id": "uuid",
"chunk_index": 0,
"file": <binary>
}
// POST /api/media/upload-chunk (循环调用,Content-Type: multipart/form-data)
// → { "ok": true }
# 3. 完成合并
POST /api/media/upload-complete
{ "upload_id": "uuid" }
Response: { "media_id": 1, "status": "pending" }
// POST /api/media/upload-complete
// → { "media_id": 1, "status": "pending" }
```
## 4. 审批 API
### 前端分片上传流程(Axios + FormData)
```python
# 管理员
GET /api/media/audit-list?status=pending&page=1&size=20
```js
// 1. 初始化
const initResp = await axios.post('/api/media/upload-init', {
filename: 'training.mp4',
file_size: 524288000,
bind_type: 'course',
bind_id: 1,
})
const { upload_id, chunk_size, chunk_count } = initResp.data.data
POST /api/media/audit/{mediaId}
// 2. 逐片上传(并发或串行)
for (let i = 0; i < chunk_count; i++) {
const chunk = file.slice(i * chunk_size, (i + 1) * chunk_size)
await axios.post('/api/media/upload-chunk', chunk, {
params: { upload_id, chunk_index: i },
headers: { 'Content-Type': 'multipart/form-data' },
})
}
// 3. 完成合并
await axios.post('/api/media/upload-complete', null, {
params: { upload_id },
})
```
---
## 5. 审批 API
### 管理员端
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/media/audit-list?status=pending&page=1&size=20` | 审批列表 |
| POST | `/api/media/audit/{mediaId}` | 审批通过/驳回 |
POST 请求体:
```json
{
"action": "approve", # approve | reject
"reject_reason": "..." # 驳回时必填
"action": "approve", // approve | reject
"reject_reason": "..." // 驳回时必填
}
```
## 5. 异步转换管线
响应:
```python
# services/media_service.py
import threading
from datetime import datetime
def _async_convert_pipeline(media_id: int):
"""审批通过后的异步转换管线"""
try:
media = db.query(MediaFile).get(media_id)
# 仅文档需要转换(PPT/Word/PDF)
if media.file_ext in ("ppt", "pptx", "doc", "docx"):
# Step 1: LibreOffice 转 PDF
pdf_path = _libreoffice_to_pdf(media.stored_path)
# Step 2: PyMuPDF 提取文本
text = _pymupdf_extract(pdf_path)
# Step 3: 按段落切片入库
chunks = _split_into_chunks(text)
for i, chunk_text in enumerate(chunks):
db.add(KnowledgeChunk(
media_file_id=media.id,
source_type=media.file_ext,
chunk_index=i,
content=chunk_text,
))
elif media.file_ext == "pdf":
# PDF 直接 PyMuPDF 提取
text = _pymupdf_extract(media.stored_path)
chunks = _split_into_chunks(text)
for i, chunk_text in enumerate(chunks):
db.add(KnowledgeChunk(media_file_id=media.id, ...))
# 视频/图片:不提取文本
media.extracted = True
db.commit()
logger.info(f"转换完成: media_id={media_id}")
except Exception as e:
logger.error(f"转换失败: media_id={media_id}, error={e}")
media.extracted = False # 标记失败可重试
def approve_media(media_id: int, auditor_id: int):
"""审批通过 → 启动异步转换"""
media = db.query(MediaFile).get(media_id)
media.status = "approved"
media.audit_by = auditor_id
media.audit_at = datetime.utcnow()
db.commit()
thread = threading.Thread(target=_async_convert_pipeline, args=(media_id,))
thread.start()
```json
{ "code": 0, "data": { "media_id": 1, "status": "approved", "audit_by": 1 } }
```
## 6. LibreOffice 转换接口
### Go handler 实现
```python
# utils/libreoffice.py
import subprocess
import requests
```go
func AuditMedia(c *gin.Context) {
mediaID, _ := strconv.ParseUint(c.Param("mediaId"), 10, 32)
var req struct {
Action string `json:"action" binding:"required"` // approve | reject
RejectReason string `json:"reject_reason"`
}
c.ShouldBindJSON(&req)
def libreoffice_convert(input_path: str, output_dir: str) -> str:
"""调用 LibreOffice 容器将文档转 PDF"""
# 方式1:本地安装 libreoffice
subprocess.run([
"libreoffice", "--headless", "--convert-to", "pdf",
"--outdir", output_dir, input_path
], check=True)
# 方式2:Docker 容器 HTTP 接口
# response = requests.post(
# f"{settings.libreoffice_url}/convert",
# files={"file": open(input_path, "rb")}
# )
# return response.json()["pdf_path"]
uid := middleware.GetUserID(c)
err := mediaSvc.Audit(c, uint(mediaID), uid, req.Action, req.RejectReason)
if err != nil { c.JSON(500, web.FAIL); return }
c.JSON(200, web.OK(gin.M{"media_id": mediaID, "status": req.Action}))
}
```
## 7. PyMuPDF 文本提取
---
```python
# utils/pdf_extractor.py
import fitz # PyMuPDF
## 6. 异步转换管线
def extract_text(pdf_path: str) -> str:
"""提取 PDF 全部文本"""
doc = fitz.open(pdf_path)
text = ""
for page in doc:
text += page.get_text()
doc.close()
return text
审批通过后,后台 goroutine 执行转换管线:
def split_into_chunks(text: str, max_chars: int = 1000) -> list[str]:
"""按段落 + 最大字符数切片"""
paragraphs = text.split("\n\n")
chunks = []
current = ""
for p in paragraphs:
if len(current) + len(p) > max_chars:
if current:
chunks.append(current.strip())
```go
// backend-go/internal/api/media.go
func approveMedia(tx *gorm.DB, mediaID uint, auditorID uint) error {
media := &model.MediaFile{}
tx.Model(media).Where("id = ?", mediaID).First(media)
media.Status = "approved"
media.AuditBy = &auditorID
now := time.Now()
media.AuditAt = &now
tx.Save(media)
go func() { _asyncConvertPipeline(tx, mediaID) }()
return nil
}
func _asyncConvertPipeline(tx *gorm.DB, mediaID uint) {
media := &model.MediaFile{}
tx.Model(media).Where("id = ?", mediaID).First(media)
// 仅文档需要转换(PPT/Word/PDF)
switch media.FileExt {
case "ppt", "pptx", "doc", "docx":
// Step 1: LibreOffice 转 PDF
pdfPath := _libreofficeToPDF(media.StoredPath)
// Step 2: pdftotext 提取文本
text := _pdftotextExtract(pdfPath)
// Step 3: 按段落切片写入 knowledge_chunk
chunks := _splitIntoChunks(text)
for i, chunkText := range chunks {
tx.Create(&model.KnowledgeChunk{
MediaFileID: media.ID,
SourceType: media.FileExt,
ChunkIndex: i,
Content: chunkText,
})
}
case "pdf":
text := _pdftotextExtract(media.StoredPath)
chunks := _splitIntoChunks(text)
for i, chunkText := range chunks {
tx.Create(&model.KnowledgeChunk{
MediaFileID: media.ID,
SourceType: "pdf",
ChunkIndex: i,
Content: chunkText,
})
}
}
// 视频/图片:不提取文本
media.Extracted = true
tx.Save(media)
}
```
---
## 7. LibreOffice 转换接口(systemd 裸进程)
```go
func _libreofficeToPDF(inputPath string) string {
// 调用本地安装的 LibreOffice(systemd 部署,非 Docker)
outDir := filepath.Dir(inputPath)
cmd := exec.Command("libreoffice",
"--headless",
"--convert-to", "pdf",
"--outdir", outDir,
inputPath,
)
cmd.Run()
return strings.TrimSuffix(inputPath, filepath.Ext(inputPath)) + ".pdf"
}
```
---
## 8. pdftotext 文本提取
```go
func _pdftotextExtract(pdfPath string) string {
// 使用系统 pdftotext(poppler-utils)提取 PDF 文本
cmd := exec.Command("pdftotext", "-layout", pdfPath, "-")
out, _ := cmd.Output()
return string(out)
}
func _splitIntoChunks(text string, maxChars int) []string {
paragraphs := strings.Split(text, "\n\n")
var chunks []string
current := ""
for _, p := range paragraphs {
if len(current)+len(p) > maxChars {
if current != "" {
chunks = append(chunks, strings.TrimSpace(current))
}
current = p
else:
current += "\n\n" + p if current else p
if current:
chunks.append(current.strip())
} else {
if current != "" {
current += "\n\n" + p
} else {
current = p
}
}
}
if current != "" {
chunks = append(chunks, strings.TrimSpace(current))
}
return chunks
```
## 8. 预览 API
```python
GET /api/media/preview/{mediaId}
# 仅 approved 素材可预览
Response:
{
"preview_url": "/media/upload/uuid-filename.pdf",
"file_ext": "pdf",
"can_preview": true
}
GET /api/media/status/{mediaId}
# 查询素材状态(含提取进度)
Response:
{
"status": "approved",
"extracted": true,
"chunk_count": 42
}
```
## 9. 安全约束
---
- 扩展名白名单:ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg
## 9. 预览与状态 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/media/preview/{mediaId}` | 仅 approved 素材可预览 |
| GET | `/api/media/status/{mediaId}` | 查询素材状态(含提取进度) |
GET `/api/media/preview/{mediaId}` 响应:
```json
{
"code": 0,
"data": {
"preview_url": "/media/upload/uuid-filename.pdf",
"file_ext": "pdf",
"can_preview": true
}
}
```
GET `/api/media/status/{mediaId}` 响应:
```json
{
"code": 0,
"data": {
"status": "approved",
"extracted": true,
"chunk_count": 42
}
}
```
---
## 10. 安全约束
- 扩展名白名单:`ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg`
- 文件名重命名为 UUID,杜绝路径穿越
- 上传目录对静态预览只读,禁止直接执行
- 文件大小:文档 ≤ 200MB,视频 ≤ 2GB
- MIME 类型校验 + 扩展名双重校验
- MIME 类型校验 + 扩展名双重校验
- SQLite 单文件,数据目录 `/opt/eai_agentplatform/data/media/`
+200 -237
View File
@@ -1,7 +1,7 @@
# BE04 — AI PathCoach 模块设计
> **版本:V1.1 | httpx 适配器 + 配置链 + Fail Fast | 参考:pj006-zhilianyuan2 llm/openai_adapter.py**
> **配置优先级:system_config 数据库表 → .env 文件**
> **版本:V2.0 | 技术:Go + Gin + GORM + SQLite + systemd + pdftotext + OpenAI 兼容接口**
> **参考:`backend-go/internal/api/ai_chat.go`**
---
@@ -9,10 +9,12 @@
- 全局 AI 聊天框的后端支持
- 上下文注入(当前产品/课程信息自动带入)
- 知识检索(MySQL FULLTEXT 召回 → Prompt 注入)
- 知识检索(Go 内 brute-force 余弦向量检索 + 关键词兜底 → Prompt 注入)
- SSE 流式响应 + 非流式调用(快捷动作)
- 3 个快捷动作(情景演练/查佣金/产品对比)
---
## 2. 架构
```
@@ -21,7 +23,8 @@
▼
┌──────────────────┐
│ 知识检索 │
│ MySQL FULLTEXT │
│ Go 内 brute-force │
│ 余弦 + 关键词 │
│ → 匹配段落 │
└──────┬───────────┘
│ 上下文片段
@@ -35,195 +38,140 @@
│
▼
┌──────────────────────────────┐
│ build_llm_adapter() 工厂 │
│ → 读取配置(库→.env) │
│ buildLLMAdapter() 工厂 │
│ → 读取配置(DB → .env) │
│ → Fail Fast 缺配置抛 501 │
│ → 返回 OpenAICompatible │
└──────┬───────────────────────┘
│
▼
┌──────────────────┐
│ httpx 调用 │
│ net/http 调用 │
│ /chat/completions│
│ SSE 流式 / 非流式│
└──────────────────┘
```
---
## 3. 配置优先级与 Fail Fast
```python
# services/ai_service.py
from __future__ import annotations
import httpx
from typing import Generator
from dataclasses import dataclass, field
from app.core.config import Settings
from app.errors import AppError
```go
// backend-go/internal/ai/llm.go
type LLMConfig struct {
BaseURL string
APIKey string
Model string
}
class LLMNotConfiguredError(AppError):
"""LLM 未配置(缺 api_key / base_url / model)"""
status_code = 501
error_code = "llm_not_configured"
func resolveLLMConfig(db *gorm.DB) (LLMConfig, error) {
// 从 system_config 表读取(优先级最高)
// 回退到 .env 文件
// 缺任何一项 → 返回错误,由 Gin handler 抛 501
baseURL := getSystemConfig(db, "llm_base_url")
apiKey := getSystemConfig(db, "llm_api_key")
model := getSystemConfig(db, "llm_model")
@dataclass
class LLMConfig:
"""LLM 连接所需的三项配置"""
base_url: str
api_key: str
model: str
def resolve_llm_config(settings: Settings) -> LLMConfig:
"""按优先级链解析 LLM 配置。缺任何一项即抛 LLMNotConfiguredError。
优先级(高 → 低):
1. settings.llm_*(来自 system_config 数据库表)
2. settings 中从 .env 读取的默认值
"""
base_url = getattr(settings, "llm_base_url", None) or ""
api_key = getattr(settings, "llm_api_key", None) or ""
model = getattr(settings, "llm_model", None) or ""
missing = []
if not base_url:
missing.append("llm_base_url")
if not api_key:
missing.append("llm_api_key")
if not model:
missing.append("llm_model")
if missing:
raise LLMNotConfiguredError(
f"LLM 服务未配置——缺失:{', '.join(missing)}。"
f"请管理员在【系统参数配置】中补充。"
)
return LLMConfig(base_url=base_url, api_key=api_key, model=model)
if baseURL == "" || apiKey == "" || model == "" {
return LLMConfig{}, fmt.Errorf("LLM 服务未配置——缺失配置项")
}
return LLMConfig{BaseURL: baseURL, APIKey: apiKey, Model: model}, nil
}
```
## 4. LLM 适配器(httpx 实现,参考 zhilianyuan2 openai_adapter.py)
---
使用 httpx 替代 OpenAI SDK,减少依赖、更可控、支持 token usage 采集。
## 4. LLM 适配器(Go net/http 实现)
```python
# services/ai_service.py
无 openai SDK 依赖,纯 Go 标准库实现,支持 SSE 流式 + token usage 采集。
class LLMAdapter:
"""OpenAI 兼容接口适配器(httpx 实现,无 openai SDK 依赖)"""
```go
// backend-go/internal/ai/llm.go
def __init__(self, *, config: LLMConfig, http_client: httpx.Client | None = None):
self._base_url = config.base_url.rstrip("/")
self._api_key = config.api_key
self._model = config.model
self._client = http_client or httpx.Client(timeout=60.0)
# 最后一次流式调用的 token 用量(供日志埋点)
self.last_stream_usage: dict[str, int] = {}
type LLMAdapter struct {
baseURL string
apiKey string
model string
client *http.Client
lastUsage map[string]int
}
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
func (a *LLMAdapter) generate(messages []map[string]interface{}, stream bool) (string, error) {
payload := map[string]interface{}{
"model": a.model,
"messages": messages,
"stream": stream,
"temperature": 0.7,
"max_tokens": 2048,
}
if stream {
payload["stream_options"] = map[string]interface{}{"include_usage": true}
}
resp, err := a.client.Post(
a.baseURL+"/chat/completions",
"application/json",
json.NewEncoder(io.NopCloser(bytes.NewBuffer(payload))),
)
if err != nil {
return "", fmt.Errorf("LLM 调用失败: %w", err)
}
defer resp.Body.Close()
if stream {
// SSE 流式处理
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil { break }
if !strings.HasPrefix(line, "data: ") { continue }
// parse SSE chunk...
}
def _payload(self, messages: list[dict], *, stream: bool, **kwargs) -> dict:
payload = {
"model": self._model,
"messages": messages,
"stream": stream,
"temperature": kwargs.get("temperature", 0.7),
"max_tokens": kwargs.get("max_tokens", 2048),
}
// 非流式:直接解析 JSON
var result struct {
Choices []struct {
Message struct { Content string }
}
if stream:
payload["stream_options"] = {"include_usage": True}
return payload
def generate(self, messages: list[dict], **kwargs) -> str:
"""非流式调用,返回完整正文。用于快捷动作等一次性请求。"""
try:
resp = self._client.post(
f"{self._base_url}/chat/completions",
headers=self._headers(),
json=self._payload(messages, stream=False, **kwargs),
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
if not content or not content.strip():
raise LLMError("LLM 返回空正文")
return content
except httpx.HTTPError as e:
raise LLMError(f"LLM 调用失败:{e}") from e
except (KeyError, ValueError) as e:
raise LLMError(f"LLM 响应解析失败:{e}") from e
def generate_stream(self, messages: list[dict], **kwargs) -> Generator[str, None, None]:
"""SSE 流式调用。逐 chunk yield 文本,末包采集 usage。"""
self.last_stream_usage = {}
try:
with self._client.stream(
"POST",
f"{self._base_url}/chat/completions",
headers=self._headers(),
json=self._payload(messages, stream=True, **kwargs),
) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line or not line.startswith("data: "):
continue
payload = line[6:].strip()
if payload == "[DONE]":
break
chunk = json.loads(payload)
# 末包采集 usage(include_usage=true)
usage = chunk.get("usage")
if usage:
self.last_stream_usage = {
"input_tokens": int(usage.get("prompt_tokens", 0) or 0),
"output_tokens": int(usage.get("completion_tokens", 0) or 0),
}
choices = chunk.get("choices", [])
if not choices:
continue
delta = choices[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except httpx.HTTPError as e:
raise LLMError(f"LLM 流式调用失败:{e}") from e
except (KeyError, ValueError) as e:
raise LLMError(f"LLM 流式响应解析失败:{e}") from e
def build_llm_adapter(settings: Settings) -> LLMAdapter:
"""工厂方法:解析配置 → 构造适配器。配置不全即 Fail Fast。"""
config = resolve_llm_config(settings)
return LLMAdapter(config=config)
Usage struct {
PromptTokens int
CompletionTokens int
}
}
json.NewDecoder(resp.Body).Decode(&result)
return result.Choices[0].Message.Content, nil
}
```
## 5. 知识检索
---
```python
# services/ai_service.py
## 5. 知识检索(Go 内 brute-force 余弦 + 关键词)
def retrieve_knowledge(keywords: str, db: Session, top_k: int = 5) -> list[str]:
"""MySQL 全文索引检索知识块"""
results = db.execute(
text(
"SELECT content FROM knowledge_chunk "
"WHERE MATCH(content) AGAINST(:keywords IN NATURAL LANGUAGE MODE) "
"LIMIT :limit"
),
{"keywords": keywords, "limit": top_k},
).fetchall()
return [r[0] for r in results]
```go
// backend-go/internal/ai/retrieve.go
func RetrieveKnowledge(db *gorm.DB, query string, topK int) ([]string, error) {
// 1. 向量检索:embedding 走 Ollama bge-m3,Go 内 brute-force 余弦相似度
queryVec := embedSingle(query) // []float32
// 2. 关键词兜底:LIKE 前缀匹配
var chunks []model.KnowledgeChunk
db.Model(&model.KnowledgeChunk{}).
Where("content LIKE ? LIMIT ?", "%"+query+"%", topK).
Find(&chunks)
// 合并去重,取 topK
return mergeAndTopK(queryVec, chunks, topK)
}
```
---
## 6. System Prompt
```python
SYSTEM_PROMPT = """你是一个博昇内部培训平台的 AI 助教 PathCoach。
```go
const SYSTEM_PROMPT = `你是一个博昇内部培训平台的 AI 助教 PathCoach。
你的职责:
1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关的问题
@@ -241,89 +189,100 @@ SYSTEM_PROMPT = """你是一个博昇内部培训平台的 AI 助教 PathCoach
知识库相关片段:
{knowledge_context}
"""
`
```
---
## 7. API 实现
```python
# api/ai_chat.py
from fastapi.responses import StreamingResponse
### 路由注册(`backend-go/internal/api/router.go`)
router = APIRouter(prefix="/api/ai-chat", tags=["ai_chat"])
@router.post("/message")
def chat_message(
request: ChatRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
"""SSE 流式对话"""
# 1. 检索知识
knowledge = retrieve_knowledge(request.message, db)
# 2. 组装 Prompt
system = SYSTEM_PROMPT.format(
page_context=json.dumps(request.context or {}),
knowledge_context="\n\n".join(knowledge),
)
messages = [
{"role": "system", "content": system},
*request.history,
{"role": "user", "content": request.message},
]
# 3. 构建 LLM 适配器(缺配置即抛 501)
llm = build_llm_adapter(settings)
def generate():
for chunk in llm.generate_stream(messages):
yield f"data: {json.dumps({'type': 'text', 'content': chunk})}\n\n"
yield "data: {\"type\": \"done\"}\n\n"
return StreamingResponse(
generate(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/quick-actions")
def get_quick_actions():
"""获取 3 个快捷按钮"""
return {"data": {"actions": [
{"id": "scenario", "label": "客户情景演练"},
{"id": "commission", "label": "查询佣金/规则"},
{"id": "compare", "label": "产品对比"},
]}}
@router.post("/quick-action")
def trigger_quick_action(
request: QuickActionRequest,
settings: Settings = Depends(get_settings),
):
"""触发快捷动作(非流式 LLM 调用)"""
llm = build_llm_adapter(settings)
if request.action_id == "commission":
# 查佣金:构建 prompt → 非流式调用
prompt = f"查询产品佣金信息,产品参数:{request.params}"
resp = llm.generate([{"role": "user", "content": prompt}])
return {"data": {"result": resp}}
elif request.action_id == "compare":
prompt = f"对比以下产品:{request.params}"
resp = llm.generate([{"role": "user", "content": prompt}])
return {"data": {"result": resp}}
elif request.action_id == "scenario":
# 情景演练:返回初始话术,后续走流式对话
prompt = f"开始销售情景演练,场景参数:{request.params}"
resp = llm.generate([{"role": "user", "content": prompt}])
return {"data": {"result": resp, "mode": "scenario"}}
```go
// ai 组
router.POST("/api/ai-chat/message", middleware.JWTAuth(), chat.ChatMessage)
router.GET("/api/ai-chat/quick-actions", middleware.JWTAuth(), chat.GetQuickActions)
router.POST("/api/ai-chat/quick-action", middleware.JWTAuth(), chat.TriggerQuickAction)
```
### ChatMessage(SSE 流式)
```go
// backend-go/internal/api/ai_chat.go
func ChatMessage(c *gin.Context) {
uid := middleware.GetUserID(c)
var req struct {
Message string `json:"message"`
Context map[string]any `json:"context"`
History []map[string]string `json:"history"`
}
c.ShouldBindJSON(&req)
// 1. 检索知识
knowledge, _ := ai.RetrieveKnowledge(store.DB, req.Message, 5)
// 2. 组装 Prompt
system := fmt.Sprintf(ai.SYSTEM_PROMPT,
"page_context": jsonEncode(req.Context),
"knowledge_context": strings.Join(knowledge, "\n\n"),
)
messages := []map[string]string{
{"role": "system", "content": system},
}
messages = append(messages, req.History...)
messages = append(messages, map[string]string{
"role": "user",
"content": req.Message,
})
// 3. 构建 LLM 适配器(缺配置即抛 501)
llm, err := ai.BuildLLMAdapter(store.DB)
if err != nil {
c.JSON(501, gin.M{"error": "llm_not_configured", "message": "请管理员在系统参数配置中补充..."})
return
}
// 4. SSE 流式响应
c.Stream(func(w io.Writer) bool {
// write SSE chunks...
return true
})
}
```
### 快捷动作
```go
func TriggerQuickAction(c *gin.Context) {
uid := middleware.GetUserID(c)
var req struct {
ActionID string `json:"action_id" binding:"required"`
Params string `json:"params"`
}
c.ShouldBindJSON(&req)
llm, _ := ai.BuildLLMAdapter(store.DB)
var prompt string
switch req.ActionID {
case "commission":
prompt = fmt.Sprintf("查询产品佣金信息,产品参数:%s", req.Params)
case "compare":
prompt = fmt.Sprintf("对比以下产品:%s", req.Params)
case "scenario":
prompt = fmt.Sprintf("开始销售情景演练,场景参数:%s", req.Params)
}
resp, _ := llm.Generate(messages)
c.JSON(200, gin.M{
"code": 0,
"data": gin.M{"result": resp},
})
}
```
---
## 8. 上下文注入规则
| 页面 | 自动注入上下文 | 说明 |
@@ -332,6 +291,8 @@ def trigger_quick_action(
| 课程详情 | `course_id`, `course_name`, `related_product` | 自动带入当前课程及关联产品 |
| 公司介绍 | `page: "company_intro"` | 提示 AI 当前页为公司介绍 |
---
## 9. 配置项
| 配置键 | 来源 | 说明 |
@@ -340,6 +301,8 @@ def trigger_quick_action(
| `llm_api_key` | system_config 表 / .env | API Key,本地 Ollama 可填 `ollama` |
| `llm_model` | system_config 表 / .env | 模型名,如 `qwen2.5:7b` |
---
## 10. 错误处理
| 场景 | HTTP 状态 | 响应 |
@@ -347,4 +310,4 @@ def trigger_quick_action(
| LLM 未配置(缺 base_url/key/model) | 501 | `{"error": "llm_not_configured", "message": "请管理员在系统参数配置中补充..."}` |
| LLM 调用超时/网络错误 | 502 | `{"error": "llm_request_failed", "message": "LLM 服务不可达,请检查网络连接"}` |
| LLM 返回空正文 | 502 | `{"error": "llm_empty_response", "message": "LLM 返回空结果"}` |
| LLM 响应格式异常 | 502 | `{"error": "llm_response_error", "message": "LLM 响应异常"}` |
| LLM 响应格式异常 | 502 | `{"error": "llm_response_error", "message": "LLM 响应异常"}` |
+255 -117
View File
@@ -1,8 +1,8 @@
# BE05 — 知识入库体系总设计(Knowledge Ingest)
> **版本:V1.1 | 定稿**
> **定位**:把「素材入库」与「结构化知识入库」统一为一套可复用的知识入库体系,覆盖上传 → 生成 → 审批 → 入库全链路。
> **配套**:BE03(素材转换管线)、BE04(AI 检索)、`docs/knowledge_source/README.md`(知识源格式契约)。
> **版本:V1.3 | 当前实现规范**
> **技术栈**:Go 后端 + SQLite + Vue3。
> **落点**:素材上传/审批/摄入、知识源(media_file/knowledge_source)审批与入库、切块与检索均由 Go 实现(`eai_agentplatform/backend-go/internal/api/media.go`、`knowledge.go`、`knowledge_pipeline.go` 等);知识分类/入库已彻底迁移到 Go,Python 的 `knowledge_service`(分类/FAISS 索引)已删除;检索为 Go 原生 brute-force 向量/关键词召回(对齐 D07/D13)。
---
@@ -19,10 +19,10 @@
---
## 2. 完整目录结构(定稿)
## 2. 完整目录结构
```
eai_agentplatform_app/
eai_agentplatform/
├── docs/
│ └── knowledge_source/ # 知识源文档(权威源,纳入 git 版本管理)
│ ├── README.md # 格式契约 + 答案契约 + 审批状态机
@@ -32,40 +32,41 @@ eai_agentplatform_app/
│ ├── 04_AI咨询与实施类.md
│ └── 05_企业级AI工具与平台.md
│
├── backend/
│ ├── app/
├── backend-go/
│ ├── config/ai_config.json # AI 路由配置(含 embed_gen/llm_fallback)
│ ├── internal/
│ │ ├── api/
│ │ │ ├── media.py # 【已有】素材上传/审批/预览
│ │ │ └── knowledge.py # 【新增】知识源扫描/审批/摄入
│ │ ├── models/
│ │ │ ├── media_file.py # 【已有】素材表
│ │ │ ├── knowledge_chunk.py # 【改造】来源扩展(见 §4)
│ │ │ └── knowledge_source.py # 【新增】知识源文档表
│ │ ├── services/
│ │ │ ├── media_service.py # 【已有】LibreOffice/PyMuPDF 转换管线
│ │ │ └── knowledge_service.py # 【新增】md 解析 + 摄入
│ │ └── scripts/
│ │ ├── init_db.py # 【已有】建表 + 种子
│ │ └── ingest_knowledge.py # 【新增】知识源扫描/摄入脚本
│ │ │ ├── media.go # 素材上传/审批/预览/提取管线
│ │ │ ├── knowledge.go # 知识源扫描/审批/摄入/解析
│ │ │ ├── knowledge_pipeline.go # 知识检索/分类/问答管线
│ │ │ └── knowledge_index.go # 知识索引重建(Go 原生)
│ │ ├── model/
│ │ │ ├── media_file.go # 素材表
│ │ │ ├── knowledge_source.go # 知识源文档表
│ │ │ ├── knowledge_chunk.go # 知识块表
│ │ │ └── question.go # 考试题目
│ │ └── store/ # DB 初始化 + GORM 连接
│ └── data/
│ ├── media/ # 【已有】素材物理文件(git 忽略)
│ │ ├── upload/
│ │ └── _preview_cache/
│ └── logs/ # 【已有】special_trace 按天日志
│
└── docker-compose.yml # 【待创建】
│ ├── eai_agentplatform.db # SQLite 单文件数据(自动建表)
│ ├── kb_data/ # 知识库物理文件(git 忽略)
│ │ ├── approved/
│ │ ├── pending/
│ │ └── rejected/
│ └── backups/ # 定期备份(git 忽略)
└── deploy/eai_agentplatform.env # 部署环境变量
```
**约定**:
- `docs/knowledge_source/` 是知识源 md 的唯一入库口(权威源,git 管理,可 diff 可回滚)。
- 运行时摄入**直接读取**该目录,不复制到 backend/data(单一事实源,避免双份漂移)。
- 素材物理文件仍在 `backend/data/media/`(git 忽略)。
- 运行时摄入**直接读取**该目录,不复制(单一事实源,避免双份漂移)。
- 素材物理文件存储在 `data/kb_data/`(git 忽略),分为 `approved/pending/rejected` 三个子目录。
- 数据库为 SQLite 单文件(`data/eai_agentplatform.db`),GORM 首次启动自动建表。
---
## 3. 两条流程的状态机(定稿)
## 3. 两条流程的状态机
### 流程 A:非结构化素材(BE03 已实现)
### 流程 A:非结构化素材
```
员工上传 ──▶ media_file(pending) ──审批──▶ approved ──▶ 异步转换
@@ -73,20 +74,20 @@ eai_agentplatform_app/
▼
┌──────────────────────────┐
│ 文档: LibreOffice→PDF→ │
│ PyMuPDF 提取→切片 │
│ pdftotext 提取→切片 │
│ 视频/图片: 仅预览 │
└──────────┬───────────────┘
▼
knowledge_chunk
```
### 流程 B:结构化知识源(本次新增)
### 流程 B:结构化知识源(知识源 md)
```
知识源 md(docs/knowledge_source/)
│
▼
【扫描】ingest_knowledge.py / POST /api/knowledge/scan
【扫描】POST /api/knowledge/scan
│ 解析 front-matter → 为每个 md 建 knowledge_source 记录
▼
knowledge_source(pending) ──审批──▶ approved ──▶ 解析摄入
@@ -102,125 +103,262 @@ knowledge_source(pending) ──审批──▶ approved ──▶ 解析摄入
---
## 4. 数据模型变更
## 4. 数据模型
### 4.1 新增 `knowledge_source` 表
### 4.1 `knowledge_source` 表
```sql
CREATE TABLE knowledge_source (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(256) NOT NULL COMMENT '文档标题',
file_path VARCHAR(512) NOT NULL UNIQUE COMMENT 'md 相对路径(docs/knowledge_source/ 下)',
category VARCHAR(64) NOT NULL COMMENT '分类:general/capital_consulting/qualification_counseling/ai_consulting/ai_tools_platform',
domain ENUM('company','product','sales') NOT NULL DEFAULT 'product',
source_version VARCHAR(32) NOT NULL COMMENT '源版本,如 V1.0',
audit_status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
audit_by BIGINT UNSIGNED DEFAULT NULL COMMENT '审批人ID',
audit_at DATETIME(6) DEFAULT NULL COMMENT '审批时间',
reject_reason VARCHAR(512) DEFAULT NULL COMMENT '驳回理由',
ingested TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已摄入',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_ks_status (audit_status),
INDEX idx_ks_category (category),
CONSTRAINT fk_ks_auditor FOREIGN KEY (audit_by) REFERENCES user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
table: knowledge_source
columns:
id uint GORM primary key (auto-increment)
title string(256) 文档标题
file_path string(512) unique 相对路径(docs/knowledge_source/ 下)
category string(64) general/capital_consulting/qualification_counseling/ai_consist/ai_tools_platform
domain string(16) company / product / sales(默认 product)
source_version string(32) 源版本,如 V1.0
audit_status string(16) pending / approved / rejected(默认 pending)
audit_by *uint 审批人 ID
audit_at *time.Time 审批时间
reject_reason string(512) 驳回理由
ingested bool 是否已摄入(默认 false)
knowledge_space_key string(64) 知识空间键
created_at time.Time
updated_at time.Time
indexes:
idx_ks_status (audit_status)
idx_ks_category (category)
```
### 4.2 改造 `knowledge_chunk` 表(来源扩展)
### 4.2 `knowledge_chunk` 表(知识块,AI 检索最小单元)
现状 `media_file_id BIGINT UNSIGNED NOT NULL` 强绑定素材。改造后:
```sql
media_file_id BIGINT UNSIGNED DEFAULT NULL, -- 由 NOT NULL 改为可空
knowledge_source_id BIGINT UNSIGNED DEFAULT NULL, -- 新增
-- 约束:media_file_id 与 knowledge_source_id 二者必居其一(应用层校验,Fail Fast)
```
table: knowledge_chunk
columns:
id uint GORM primary key
media_file_id *uint 二选一(素材来源)
knowledge_source_id *uint 二选一(知识源来源)
source_type string(32) pdf/doc/md(来源类型标识)
source_id string(64) 来源 ID 字符串
knowledge_space_key string(64) 知识空间键
chunk_index int 块序号
content text 内容正文
created_at time.Time
indexes:
idx_mf (media_file_id)
idx_ks (knowledge_source_id)
idx_key (knowledge_space_key)
```
对应 ORM `models/knowledge_chunk.py`:`media_file_id` 改为可空,新增 `knowledge_source_id` 外键与 relationship。
### 4.3 `media_file` 表(素材文件)
```
table: media_file
columns:
id uint GORM primary key
file_name string(512) 文件名
file_path string(512) 物理路径(data/kb_data/...)
file_size int64 文件大小
file_type string(64) MIME 类型
status string(16) pending / approved / rejected(默认 pending)
approved_at *time.Time 审批时间
audit_by *uint 审批人 ID
audit_status string(16) 审批状态
file_category string(64) 分类(培训/产品/规则等)
extract_status string(64) 提取状态(pending / extracting / completed / failed)
created_at time.Time
updated_at time.Time
indexes:
idx_status (status)
idx_category (file_category)
```
---
## 5. 模块清单(8 模块)
## 5. 模块清单
| # | 模块 | 状态 | 职责 | 落点 |
|---|------|------|------|------|
| 1 | 上传模块 | ✅ 已有 | 直传 + 分片(>100MB) | `api/media.py` |
| 2 | 转换模块 | ✅ 已有 | LibreOffice → PDF | `services/media_service.py` |
| 3 | 提取切片模块 | ✅ 已有 | PyMuPDF 提取 + 段落切片 | `services/media_service.py` |
| 4 | **摄入模块** | 🆕 新增 | 解析知识源 md → product/question/chunk | `services/knowledge_service.py` |
| 5 | 审批模块 | 🔧 扩展 | 素材审批(已有)+ 知识源审批(新增) | `api/media.py` + `api/knowledge.py` |
| 6 | 入库模块 | 🔧 扩展 | 写 product/question/knowledge_chunk | `services/knowledge_service.py` |
| 7 | 预览模块 | ✅ 已有 | approved 素材预览 | `api/media.py` |
| 8 | 检索模块 | ✅ 已有 | MySQL FULLTEXT 召回 | `services/ai_service.py` |
| 1 | 上传模块 | ✅ | 直传 + 分片(>100MB) | `api/media.go` |
| 2 | 转换模块 | ✅ | LibreOffice→PDF,pdftotext→文本 | `api/media.go` |
| 3 | 提取切片模块 | ✅ | PDF→文本→段落切片 | `api/media.go` |
| 4 | 摄入模块 | ✅ | 解析知识源 md → product/question/chunk | `api/knowledge.go` |
| 5 | 审批模块 | ✅ | 素材审批 + 知识源审批 | `api/media.go` + `api/knowledge.go` |
| 6 | 检索模块 | ✅ | brute-force 向量/关键词召回 | `api/knowledge_pipeline.go` |
| 7 | 索引重建 | ✅ | 知识索引异步重建(Go 原生) | `api/knowledge_index.go` |
| 8 | 问答管线 | ✅ | 意图分类 → FAQ 匹配 → 向量/关键词检索 → LLM 兜底 | `api/knowledge_pipeline.go` |
---
## 6. 新增 API(knowledge.py)
## 6. API 端点
```python
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
### 知识源管理
# ── 扫描(管理员)──
POST /api/knowledge/scan
# 扫描 docs/knowledge_source/*.md
# 为新增/变更的 md 建 knowledge_source 记录(status=pending)
# 已存在且未摄入的记录跳过;返回扫描结果列表
| 端点 | 方法 | 职责 |
|------|------|------|
| `POST /api/knowledge/scan` | 管理员 | 扫描 `docs/knowledge_source/*.md`,为新增/变更的 md 建 `knowledge_source` 记录(status=pending)。已存在且未摄入的记录跳过。 |
| `GET /api/knowledge/audit-list?status=&page=&size=` | 管理员 | 按 `audit_status` 过滤,分页返回知识源列表。 |
| `POST /api/knowledge/audit/{sourceId}` | 管理员 | `{ "action": "approve" | "reject", "reject_reason": "..." }`。approve → 触发摄入(同步解析写入 product/question/chunk,标记 `ingested=1`,然后异步重建索引);reject → 必填 `reject_reason`,不摄入。 |
| `GET /api/knowledge/status/{sourceId}` | 管理员 | 返回 `{ "audit_status", "ingested", "reject_reason", "knowledge_space_key" }`。 |
# ── 审批(管理员)──
GET /api/knowledge/audit-list?status=pending
POST /api/knowledge/audit/{source_id}
# { "action": "approve" | "reject", "reject_reason": "..." }
# approve → 触发摄入(同步解析写入 product/question/chunk,标记 ingested=1)
# reject → 必填 reject_reason,不摄入
### 素材管理
# ── 状态查询 ──
GET /api/knowledge/status/{source_id}
# { "audit_status", "ingested", "reject_reason" }
| 端点 | 方法 | 职责 |
|------|------|------|
| `POST /api/media/upload` | 通用 | 文件上传(直传) |
| `POST /api/media/upload/init` | 通用 | 分片上传初始化(>100MB) |
| `POST /api/media/upload/chunk` | 通用 | 分片上传分片 |
| `POST /api/media/upload/complete` | 通用 | 分片上传完成 |
| `POST /api/media/audit/{fileId}` | 管理员 | `{ "action": "approve" | "reject" }`。approve → 触发异步提取;reject → 素材标记 rejected。 |
| `GET /api/media/preview/{fileId}` | 通用 | 预览素材(PDF/图片/视频) |
| `GET /api/knowledge/index/rebuild` | 管理员 | 手动触发知识索引重建(Go 原生,读取 `knowledge_chunk` + `media_file` + `knowledge_source` 全量数据重建索引)。返回 `{ "rebuild": true, "chunk_count": N, "service_enabled": false }`。 |
---
## 7. 摄入逻辑(`api/knowledge.go:ingestSource`)
### 解析规则
知识源 md 采用 **YAML front-matter + 三个固定 `## ` section** 格式,Go 后端按如下规则解析:
```
---
title: 文档标题
category: 分类
domain: domain(product/company/sales)
version: V1.0
---
```
---
然后按 `## ` 二级标题切分三个 section:
## 7. 摄入脚本(ingest_knowledge.py)
| Section 名 | 内容 | 解析 |
|-----------|------|------|
| `结构化产品数据` | 每个 `### code name` 键值对列表 → product | 解析 `code`/`name`/`category`/`tags`/`description`/`pricing`/`commission_recommend`/`commission_negotiate`/`public_course_bonus`/`version_risk`/`report_rules` |
| `AI 检索知识` | 每个 `### 标题` 段落 → knowledge_chunk | 段落正文写入 content,source_type=md |
| `考试题目` | 每个 `### Qn` 字段列表 → question | 解析 `type`/`stem`/`options`(JSON)/`answer`(JSON)/`explanation`/`domain` |
```python
"""知识源摄入脚本:扫描 md → 建记录 → (审批通过后)解析入库
### 断点摄入
用法:
cd backend && source venv/bin/activate
python -m app.scripts.ingest_knowledge --scan # 仅扫描建 pending 记录
python -m app.scripts.ingest_knowledge --ingest <source_id> # 摄入单个已审批源
"""
`ingestSource` 先检查 `src.Ingested`:若已为 true,直接返回(跳过重复解析写入)。
# 解析规则(严格对齐 knowledge_source/README.md 格式契约):
# 1. 读 YAML front-matter:category / domain / source_version
# 2. 按 "## " 二级标题切块:
# ## 结构化产品数据 → 解析 "### code name" + "key: value" 列表 → product
# ## AI 检索知识 → 每个 "### 标题" 段落 → knowledge_chunk
# ## 考试题目 → 每个 "### Qn" 的字段列表 → question
# 3. 摄入后 product/question 置 status=active
# 4. knowledge_chunk 写入 knowledge_source_id,media_file_id 留空
### 写入逻辑
```go
// ingestSource 伪代码
func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
data, _ := os.ReadFile(filepath.Join(Cfg.KnowledgeSourceDir, src.FilePath))
fm := parseFrontMatter(string(data))
sections := splitSections(string(data))
// 1. 产品数据 → product 表(upsert:按 code 查,已存在则更新)
for _, b := range splitBlocks(sections["结构化产品数据"]) {
kv := parseKV(b.body)
p := model.Product{Code: kv["code"], Name: kv["name"], ...}
store.DB.Save(&p) // upsert by code
}
// 2. AI 检索知识 → knowledge_chunk
for i, b := range splitBlocks(sections["AI 检索知识"]) {
store.DB.Create(&model.KnowledgeChunk{
KnowledgeSourceID: &src.ID,
SourceType: "md",
SourceID: strconv.FormatUint(uint64(src.ID), 10),
Content: b.body,
})
}
// 3. 考试题目 → question
for _, b := range splitBlocks(sections["考试题目"]) {
q := model.Question{...}
store.DB.Create(&q)
}
}
```
**Fail Fast 约束(G02)**:解析失败(front-matter 缺失、section 缺块、字段缺失)立即抛错并中断该源摄入,不静默跳过、不写半截数据。
### 断点约束
解析失败(front-matter 缺失、section 缺块、字段缺失)静默跳过对应块,不抛错中断。每个 section 的解析独立于其他 section。
---
## 8. 格式契约(已定,见 knowledge_source/README.md)
## 8. 格式契约
- 每个 md:YAML front-matter(`category` / `domain` / `source_version`)+ 三个固定 `## ` section。
- 题目答案契约:`judge=[bool]`、`single=[索引]`、`multiple=[索引列表]`。
- 已生成 5 个知识源:`01_通用规则` + `02/03/04/05` 四大分类,共 28 产品 + 20 题。
- 题目格式:
- 判断题:`- judge: true/false` + `- stem: 题干` + `- explanation: 解析`
- 单选题:`- type: single` + `- stem: 题干` + `- options: ["A. ...", "B. ..."]` + `- answer: [1]` + `- explanation: 解析`
- 多选题:`- type: multiple` + `- stem: 题干` + `- options: [...]` + `- answer: [1,2]` + `- explanation: 解析`
- 选项格式:`- 选项文本`(Go 端自动编号 A/B/C/D...)
- 已生成 5 个知识源:`01_通用规则` + `02/03/04/05` 四大分类。
---
## 9. 实现清单(代码侧,已完成)
## 9. 知识检索管线(`api/knowledge_pipeline.go`)
- [x] `models/knowledge_source.py` 新建
- [x] `models/knowledge_chunk.py` 来源扩展(media_file_id 可空 + knowledge_source_id)
- [x] `services/knowledge_service.py`(md 解析 + 摄入)
- [x] `api/knowledge.py`(scan / audit / status)
- [x] `scripts/ingest_knowledge.py`
- [x] `scripts/init_db.py` 追加 knowledge_source 建表 + knowledge_chunk 字段迁移
- [x] `docs/db_schema.md`、`docs/api.md` 同步更新
知识检索采用 **四层管线**,逐层降级:
```
用户提问
│
▼ ① 意图分类器(heuristicKnowledgeIntent)
│ 关键词规则匹配 → intent: invalid / smalltalk / out_of_scope / faq / document
│ 命中 smalltalk/out_of_scope → 直接返回答案,管线终止
│
▼ ② FAQ 匹配(matchKnowledgeFAQ)
│ 问题归一化 + 相似问题匹配 + 关键词重叠评分
│ 最佳分 >= 70 → 返回标准答案,管线终止
│
▼ ③ 向量检索(vectorRetrieveCitations)
│ 1) 调用 embed_gen 路由嵌入 query + 候选块(brute-force)
│ 2) 计算余弦相似度,降序排序
│ 3) 最高分 >= 0.35 且不需要 LLM 兜底 → 直接给出结果
│
▼ ④ LLM 兜底(llm_fallback)
│ 将检索结果 + 系统提示词发送给 LLM
│ LLM 基于知识片段生成最终回答
```
### 检索技术细节
- **嵌入路由**:通过 `config.GetRoute("embed_gen")` 获取嵌入模型配置,调用 Ollama embedding API。
- **向量检索**:brute-force 全量候选块嵌入 + 余弦相似度排序(对齐 D07/D13),不依赖外部向量库。
- **关键词兜底**:当嵌入模型不可用时(`embed_gen` 路由缺失/调用失败),降级为 `keywordRetrieveCitations`——term 重叠评分排序。
- **空间过滤**:支持按 `knowledge_space_key` 过滤候选块(如选择某知识库后再提问)。
- **直接回答判定**:`canDirectAnswerFromVector` 判定:最高分 ≥ 0.35 且不含 LLM 兜底关键词 → 直接返回。
- **LLM 兜底判定**:`needsLLMFallback` 检测 query 含「总结/梳理/分析/对比/归纳/起草/生成/提纲/报告」等关键词 → 强制 LLM 处理。
---
## 10. 知识索引重建(`api/knowledge_index.go`)
知识索引为 Go 原生实现,异步触发:
```
triggerKnowledgeIndexRebuild()
└─ go func()
└─ rebuildKnowledgeIndexNow()
└─ buildKnowledgeIndexItems()
├─ 读取 knowledge_chunk 全量
├─ 读取 approved media_file
├─ 读取 approved knowledge_source
└─ 合并 → 构建索引项列表
```
- 触发时机:知识源审批通过摄入后(`ingestSource` 成功后)、素材提取管线完成切片后。
- 锁机制:`knowledgeIndexRebuildLock` 防止并发重建。
- 索引项字段:`ID, Title, Content, ChunkIndex, SourceType, SourceID, KnowledgeSpaceKey, KnowledgeSpaceName`。
- 管理员也可通过 `POST /api/knowledge/index/rebuild` 手动触发重建。
---
## 11. 实现清单(Go 代码,已完成)
- [x] `internal/model/knowledge_source.go` — 知识源表
- [x] `internal/model/knowledge_chunk.go` — 知识块表(来源扩展:media_file_id/knowledge_source_id 二选一)
- [x] `internal/model/media_file.go` — 素材表(status + extract_status 字段)
- [x] `api/knowledge.go` — scan / audit / status / ingestSource(md 解析 + 摄入)
- [x] `api/media.go` — 上传 / 审批 / 提取管线 / 预览
- [x] `api/knowledge_pipeline.go` — 意图分类 / FAQ / 向量检索 / LLM 兜底
- [x] `api/knowledge_index.go` — 知识索引重建(Go 原生)
- [x] `internal/store/` — DB 初始化 + 自动建表
- [x] `config/ai_config.json` — AI 路由配置(embed_gen / llm_fallback)
@@ -1,7 +1,7 @@
# BE06 — AI 配置与算力点计费模块设计
> **版本:V1.0 | 从 pj034-oeamgt 完整移植 + 精简适配 | 参考:pj034 `core/ai_config.py`、`services/ai/router.py`、`models/ai_call_log.py`、`api/v1/ai_billing.py`**
> **目标:把 pj034 成熟的「AI 路由/Provider/密钥配置 + 算力点计费 + 调用审计」体系,按 pj0231「极致极简、单公司、按用户计点」落地。**
> **版本:V2.0 | Go + Gin + GORM + SQLite | 参考:`backend-go/internal/api/ai_admin.go`、`backend-go/internal/api/ai_usage.go`、`backend-go/internal/api/ai_chat.go`、`backend-go/internal/ai/credits.go`**
> **目标:把已有 AI 路由/Provider/密钥配置 + 算力点计费 + 调用审计体系,按「极致极简、单公司、按用户计点」落地。**
---
@@ -60,7 +60,7 @@ pj034 的 AI 系统已演进为「多层、多公司、多租户」的完整运
| `provider` | enum | provider | ✅ |
| `capability` | enum | AI 能力 | ✅ |
| `input_asset_id` / `output_asset_id` | int | 电商素材 | ❌ |
| `route_id` | str | 路由 ID | ✅ |
| `ai_route_id` | str | AI 路由 ID | ✅ |
| `model_id` | str | 模型 | ✅ |
| `input_summary` / `output_summary` | text | 摘要 | ❌ 极致极简 |
| `raw_request` / `raw_response` | text | 原始 IO | ❌(审计可后补) |
@@ -100,7 +100,7 @@ def compute_credits(capability, billing_mode, status):
`GET /data/ai-billing?days=30&group_by=month|capability|provider|month_capability`
返回 `{ summary: {total_calls, success_calls, failed_calls, total_credits_charged}, buckets: [...] }`。MySQL 下按月聚合走 Go 侧 group(不依赖 `date_trunc`)。
返回 `{ summary: {total_calls, success_calls, failed_calls, total_credits_charged}, buckets: [...] }`。SQLite 下按月聚合走 Go 侧 group。
---
@@ -130,7 +130,7 @@ def compute_credits(capability, billing_mode, status):
| `user_id` | uint index | 调用人 |
| `capability` | str | ai_chat / text_gen / embed |
| `provider` | str | 实际命中的 provider |
| `route_id` | str | 路由 ID |
| `ai_route_id` | str | AI 路由 ID |
| `model` | str | 模型名 |
| `tokens_input` | int | 输入 token |
| `tokens_output` | int | 输出 token |
+2 -2
View File
@@ -3,7 +3,7 @@
> **命名规则:** `BE{NN}_{描述}.md`
> **用途:** 后端实现细节、API 设计、服务层设计
>
> **⚠️ 本目录 BE 文档为 V1.1 设计期历史快照(FastAPI/Python)。** 当前后端已重写为 **Go + Gin + GORM + MySQL 8.0 + FAISS**,见 `docs/changelog.md`(V1.2)。V1.4–V1.7 新增的岗位/积分/证书/部门/消息等接口以 `docs/changelog.md` 为准。
> **当前后端为 Go + Gin + GORM + SQLite。** 详见 `docs/changelog.md`。
## 文件清单
@@ -15,4 +15,4 @@
| `BE03_Media_Module.md` | 素材模块设计(上传/审批/转换管线) |
| `BE04_AI_Chat_Module.md` | AI PathCoach 模块设计(V1 单一助手;智能体矩阵见 SY03) |
| `BE05_Knowledge_Ingest_Module.md` | 知识入库体系设计(上传→生成→审批→入库) |
| `BE06_AI_Config_Credits_Module.md` | AI 配置与算力点计费模块(移植自 pj034) |
| `BE06_AI_Config_Credits_Module.md` | AI 配置与算力点计费模块 |