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:
@@ -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`)
|
||||
|
||||
@@ -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/`
|
||||
@@ -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 响应异常"}` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 配置与算力点计费模块 |
|
||||
|
||||
Reference in New Issue
Block a user