feat: 知识库管理后台上传/列表/删除API,含frontmatter解析

This commit is contained in:
simonkoson
2026-05-26 18:51:12 +08:00
parent d81969b4b5
commit 855f103ce8
3 changed files with 267 additions and 42 deletions
+92
View File
@@ -0,0 +1,92 @@
"""
知识库 API — 上传 / 列表 / 删除 / 来源筛选
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, status
from sqlmodel import Session
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.user import User, UserRole
from app.services.knowledge_service import KnowledgeService
router = APIRouter(prefix="/api/knowledge", tags=["知识库"])
@router.post("/upload")
async def upload_md_files(
files: list[UploadFile] = File(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""
上传单个或多个 .md 文件,解析 frontmatter,写入知识库(含向量)。
仅制片人/责编可用。
"""
svc = KnowledgeService()
results = []
errors = []
for f in files:
if not f.filename.endswith(".md"):
errors.append({"file": f.filename, "error": "仅支持 .md 文件"})
continue
try:
content = await f.read()
item = svc.store_md_file(content, f.filename)
results.append({
"id": item.id,
"title": item.title,
"source_type": item.source_type,
})
except Exception as e:
errors.append({"file": f.filename, "error": str(e)})
return {"uploaded": results, "errors": errors}
@router.get("/items")
def list_knowledge_items(
source_type: Optional[str] = Query(None),
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""
知识库列表,支持按 source_type 筛选。三角色均可读。
"""
svc = KnowledgeService()
items = svc.list_items(source_type=source_type)
return items
@router.delete("/items/{item_id}")
def delete_knowledge_item(
item_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""
删除知识库条目(级联删除向量)。仅制片人/责编可用。
"""
svc = KnowledgeService()
deleted = svc.delete_item(item_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="条目不存在")
return {"message": "删除成功"}
@router.get("/sources")
def list_distinct_sources(
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""
返回库里所有不重复的具体出处(source_detail),供来源筛选下拉动态生成。
三角色均可读。
"""
svc = KnowledgeService()
sources = svc.get_distinct_sources()
return [{"source": s} for s in sources]
+2
View File
@@ -16,6 +16,7 @@ from app.api.users import router as users_router
from app.api.yearly_targets import router as yearly_targets_router from app.api.yearly_targets import router as yearly_targets_router
from app.api.dashboard import router as dashboard_router from app.api.dashboard import router as dashboard_router
from app.api.schedules import router as schedules_router from app.api.schedules import router as schedules_router
from app.api.knowledge import router as knowledge_router
from app.core.config import settings from app.core.config import settings
app = FastAPI(title="军事科技工作台", version="0.1.0") app = FastAPI(title="军事科技工作台", version="0.1.0")
@@ -49,6 +50,7 @@ app.include_router(yearly_targets_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
app.include_router(schedules_router) app.include_router(schedules_router)
app.include_router(knowledge_router)
# 挂载静态文件目录(题图海报) # 挂载静态文件目录(题图海报)
_static_dir = Path(__file__).parent.parent / "static" _static_dir = Path(__file__).parent.parent / "static"
+173 -42
View File
@@ -1,10 +1,12 @@
""" """
知识库服务 — 写入向量 + 语义检索 知识库服务 — 写入向量 + 语义检索 + md 文件解析
使用 pgvector 原生 SQL 向量检索(<=> 余弦距离算子),不在 Python 侧计算 使用 pgvector 原生 SQL 向量检索(<=> 余弦距离算子),不在 Python 侧计算
""" """
from typing import Optional from typing import Optional
from datetime import date
import frontmatter
from sqlalchemy import text from sqlalchemy import text
from sqlmodel import Session, select from sqlmodel import Session, select
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import Vector
@@ -15,43 +17,141 @@ from app.db.session import engine
class KnowledgeService: class KnowledgeService:
"""知识库 CRUD + 语义检索""" """知识库 CRUD + 语义检索 + md 解析"""
# yaml 类型字段 → source_type 枚举映射
SOURCE_TYPE_MAP = {
"杂志文章": "military_report",
"军报": "military_report",
"节目文稿": "manuscript",
"报题单": "baoti",
}
def __init__(self): def __init__(self):
self.embedder = EmbeddingService() self.embedder = EmbeddingService()
def store_md_file( def parse_md_file(self, file_content: bytes, file_name: str) -> dict:
self, """
title: str, 解析一个 .md 文件的 yaml frontmatter + 正文,返回入库用的字典。
content_md: str, 严格按真实样本的字段名映射,不猜测。
source_file_name: Optional[str] = None,
source_type: str = "manual", Returns:
author: Optional[str] = None, dict 含 keys: title, content_md, source_type, author, publish_date,
) -> KnowledgeItem: source_detail, metadata(JSONB), related_entities(JSONB)
"""
content = file_content.decode("utf-8", errors="replace")
parsed = frontmatter.loads(content)
fm = parsed.metadata or {}
# —— 类型 → source_type(硬映射,不猜测)——
raw_type = str(fm.get("类型", "")).strip()
source_type = self.SOURCE_TYPE_MAP.get(raw_type, "manual")
# —— 标题:名称 或 标题——
title = str(fm.get("名称", "") or fm.get("标题", "")).strip()
if not title:
# fallback: 用正文第一行或文件名
lines = [l.strip() for l in content.split("\n") if l.strip() and not l.strip().startswith("---")]
title = lines[0] if lines else file_name
# —— 作者:作者 或 编导——
author = str(fm.get("作者", "") or fm.get("编导", "") or "").strip() or None
# —— 出处详情:期刊 + 期号(拼在一起存进 JSONB 的 source_detail)——
journal = str(fm.get("期刊", "") or "").strip()
issue = str(fm.get("期号", "") or "").strip()
if journal or issue:
source_detail = f"{journal} {issue}".strip()
else:
source_detail = None
# —— 播出日期:容错 "待补充" 等非日期文本——
raw_date = str(fm.get("播出日期", "") or "").strip()
publish_date = None
if raw_date and raw_date not in ("待补充", "待确认", ""):
try:
publish_date = date.fromisoformat(raw_date)
except ValueError:
# 非 ISO 格式,尝试 common 格式
for fmt in ("%Y-%m-%d", "%Y年%m月%d", "%Y/%m/%d"):
try:
publish_date = date.fromisoformat(raw_date.replace("", "-").replace("", "-").replace("", ""))
break
except ValueError:
continue
# —— 权重(不展示,存 JSONB 备 Phase 4)——
weight = str(fm.get("权重", "") or "").strip() or None
# —— 相关实体(涉及装备/涉及技术/涉及厂商/主题)——
related_entities = []
for key in ("涉及装备", "涉及技术", "涉及厂商", "主题"):
val = fm.get(key)
if val:
if isinstance(val, list):
related_entities.extend(val)
elif isinstance(val, str):
# 可能是 "山东舰, 福建舰" 这样的逗号分隔字符串
for item in val.replace("", ",").split(","):
item = item.strip()
if item:
related_entities.append(item)
# —— metadata JSONB:权重、出处详情、双链预留——
metadata = {}
if weight:
metadata["weight"] = weight
if source_detail:
metadata["source_detail"] = source_detail
# related_concepts 字段预留给双链解析(Phase 4),本 Task 原样存入
metadata["double_bracket_links"] = self._extract_double_brackets(parsed.content)
# —— 正文(去掉 frontmatter 的纯内容)——
content_md = parsed.content
return {
"title": title,
"content_md": content_md,
"source_type": source_type,
"author": author,
"publish_date": publish_date,
"metadata": metadata if metadata else None,
"related_entities": related_entities if related_entities else None,
"source_file_name": file_name,
}
def _extract_double_brackets(self, text: str) -> list[str]:
"""提取 [[...]] 双链标记,原样返回列表,不解析成图谱(本 Task 留门)。"""
import re
return re.findall(r"\[\[([^\]]+)\]\]", text)
def store_md_file(self, file_content: bytes, file_name: str) -> KnowledgeItem:
""" """
读取一篇 md 内容,调用 embo-01 拿到向量,写入 knowledge_items + knowledge_embeddings 读取一篇 md 内容,调用 embo-01 拿到向量,写入 knowledge_items + knowledge_embeddings
""" """
parsed = self.parse_md_file(file_content, file_name)
# 调用 embeddingtype="db" 表示存入知识库) # 调用 embeddingtype="db" 表示存入知识库)
embedding_list = self.embedder.embed_single(content_md, embed_type="db") embedding_list = self.embedder.embed_single(parsed["content_md"], embed_type="db")
with Session(engine) as session: with Session(engine) as session:
# 写入 knowledge_items
item = KnowledgeItem( item = KnowledgeItem(
title=title, title=parsed["title"],
content_md=content_md, content_md=parsed["content_md"],
source_type=source_type, source_type=parsed["source_type"],
source_file_name=source_file_name, source_file_name=parsed["source_file_name"],
author=author, author=parsed["author"],
publish_date=parsed["publish_date"],
tags=parsed["metadata"],
related_entities=parsed["related_entities"],
) )
session.add(item) session.add(item)
session.flush() # 拿到 id session.flush()
# 写入 knowledge_embeddings(单 chunkchunk_index=0
# 直接传 listpgvector.sqlalchemy.Vector 会自动处理转换
emb = KnowledgeEmbedding( emb = KnowledgeEmbedding(
knowledge_id=item.id, knowledge_id=item.id,
chunk_index=0, chunk_index=0,
chunk_text=content_md, chunk_text=parsed["content_md"],
embedding=embedding_list, embedding=embedding_list,
) )
session.add(emb) session.add(emb)
@@ -59,20 +159,61 @@ class KnowledgeService:
session.refresh(item) session.refresh(item)
return item return item
def delete_item(self, knowledge_id: int) -> bool:
"""删除知识库条目及其向量(CASCADE 已由 DB 层配置)。"""
with Session(engine) as session:
item = session.get(KnowledgeItem, knowledge_id)
if item is None:
return False
session.delete(item)
session.commit()
return True
def list_items(self, source_type: Optional[str] = None) -> list[dict]:
"""返回知识库条目列表(含 source_detail 从 metadata 解压)。"""
with Session(engine) as session:
statement = select(KnowledgeItem).order_by(KnowledgeItem.created_at.desc())
if source_type:
statement = statement.where(KnowledgeItem.source_type == source_type)
items = session.exec(statement).all()
results = []
for item in items:
# 从 tags(JSONB) 取 source_detail
tags = item.tags or {}
source_detail = tags.get("source_detail") if isinstance(tags, dict) else None
results.append({
"id": item.id,
"title": item.title,
"author": item.author,
"publish_date": item.publish_date,
"source_type": item.source_type,
"source_file_name": item.source_file_name,
"source_detail": source_detail,
"created_at": item.created_at,
})
return results
def get_distinct_sources(self) -> list[str]:
"""返回库里所有不重复的 source_detail(动态从 JSONB 提取),供筛选下拉用。"""
with Session(engine) as session:
items = session.exec(select(KnowledgeItem)).all()
sources = set()
for item in items:
tags = item.tags or {}
if isinstance(tags, dict) and tags.get("source_detail"):
sources.add(tags["source_detail"])
return sorted(list(sources))
def search_similar(self, query_text: str, top_k: int = 5) -> list[dict]: def search_similar(self, query_text: str, top_k: int = 5) -> list[dict]:
""" """
语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索 语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索
返回 top_k 条相似笔记,含相似度分数 返回 top_k 条相似笔记,含相似度分数
""" """
# 查询向量(type="query"
query_vector = self.embedder.embed_single(query_text, embed_type="query") query_vector = self.embedder.embed_single(query_text, embed_type="query")
# 将向量列表转为 pgvector SQL 字符串格式
vec_str = "[" + ",".join(str(v) for v in query_vector) + "]" vec_str = "[" + ",".join(str(v) for v in query_vector) + "]"
with Session(engine) as session: with Session(engine) as session:
# pgvector 原生 SQL<=> 是余弦距离,1 - 距离 = 相似度
# 用字符串插注向量,避免 psycopg2 参数化问题
sql = f""" sql = f"""
SELECT SELECT
ki.id, ki.id,
@@ -87,25 +228,15 @@ class KnowledgeService:
""" """
stmt = text(sql) stmt = text(sql)
rows = session.execute(stmt).all() rows = session.execute(stmt).all()
return [
results = [] {"id": r.id, "title": r.title, "source_type": r.source_type, "similarity": round(r.similarity, 4)}
for row in rows: for r in rows
results.append({ ]
"id": row.id,
"title": row.title,
"source_type": row.source_type,
"similarity": round(row.similarity, 4),
})
return results
def get_item_count(self) -> int: def get_item_count(self) -> int:
"""返回 knowledge_items 表行数"""
with Session(engine) as session: with Session(engine) as session:
count = session.exec(select(KnowledgeItem)).all() return len(session.exec(select(KnowledgeItem)).all())
return len(count)
def get_embedding_count(self) -> int: def get_embedding_count(self) -> int:
"""返回 knowledge_embeddings 表行数"""
with Session(engine) as session: with Session(engine) as session:
count = session.exec(select(KnowledgeEmbedding)).all() return len(session.exec(select(KnowledgeEmbedding)).all())
return len(count)