feat: 知识库管理后台上传/列表/删除API,含frontmatter解析
This commit is contained in:
@@ -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]
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
# 调用 embedding(type="db" 表示存入知识库)
|
# 调用 embedding(type="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(单 chunk,chunk_index=0)
|
|
||||||
# 直接传 list,pgvector.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)
|
|
||||||
Reference in New Issue
Block a user