Files
tps-dashboard/backend/app/services/knowledge_service.py
T

338 lines
14 KiB
Python
Raw Normal View History

"""
知识库服务 — 写入向量 + 语义检索 + md 文件解析
使用 pgvector 原生 SQL 向量检索(<=> 余弦距离算子),不在 Python 侧计算
"""
from typing import Optional
from datetime import date
import frontmatter
from sqlalchemy import text
from sqlmodel import Session, select
from pgvector.sqlalchemy import Vector
from app.models.knowledge import KnowledgeItem, KnowledgeEmbedding
from app.services.embedding_service import EmbeddingService
from app.db.session import engine
class KnowledgeService:
"""知识库 CRUD + 语义检索 + md 解析"""
# yaml 类型字段 → source_type 枚举映射
SOURCE_TYPE_MAP = {
"杂志文章": "military_report",
"军报": "military_report",
"节目文稿": "manuscript",
"报题单": "baoti",
}
# 来源大类固定显示顺序(制片人 Obsidian 习惯)
SOURCE_TYPE_ORDER = [
"manuscript", # 节目文稿
"military_report", # 杂志文章
"baoti", # 报题单
"manual", # 其他
]
# 二级分组维度映射(与前端 useKnowledgeGrouping.js 的 SECONDARY_GROUP_FIELD 一致)
# key = source_type, value = 用来做二级分组的字段名,None = 不建二级节点
SECONDARY_GROUP_FIELD = {
"manuscript": "author", # 节目文稿 → 按作者(编导)
"military_report": "source_detail", # 杂志文章 → 按出处
"baoti": None, # 报题单 → 不分组
"manual": None, # 其他 → 不分组
}
SOURCE_TYPE_LABEL = {
"military_report": "杂志文章",
"manuscript": "节目文稿",
"baoti": "报题单",
"manual": "其他",
}
def __init__(self):
self.embedder = EmbeddingService()
def parse_md_file(self, file_content: bytes, file_name: str) -> dict:
"""
解析一个 .md 文件的 yaml frontmatter + 正文,返回入库用的字典。
严格按真实样本的字段名映射,不猜测。
Returns:
dict 含 keys: title, content_md, source_type, author, publish_date,
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
"""
parsed = self.parse_md_file(file_content, file_name)
# 调用 embeddingtype="db" 表示存入知识库)
embedding_list = self.embedder.embed_single(parsed["content_md"], embed_type="db")
with Session(engine) as session:
item = KnowledgeItem(
title=parsed["title"],
content_md=parsed["content_md"],
source_type=parsed["source_type"],
source_file_name=parsed["source_file_name"],
author=parsed["author"],
publish_date=parsed["publish_date"],
tags=parsed["metadata"],
related_entities=parsed["related_entities"],
)
session.add(item)
session.flush()
emb = KnowledgeEmbedding(
knowledge_id=item.id,
chunk_index=0,
chunk_text=parsed["content_md"],
embedding=embedding_list,
)
session.add(emb)
session.commit()
session.refresh(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]:
"""
语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索
返回 top_k 条相似笔记,含相似度分数
"""
query_vector = self.embedder.embed_single(query_text, embed_type="query")
vec_str = "[" + ",".join(str(v) for v in query_vector) + "]"
with Session(engine) as session:
sql = f"""
SELECT
ki.id,
ki.title,
ki.source_type,
1 - (ke.embedding <=> '{vec_str}'::vector) AS similarity
FROM knowledge_embeddings ke
JOIN knowledge_items ki ON ke.knowledge_id = ki.id
WHERE ke.chunk_index = 0
ORDER BY ke.embedding <=> '{vec_str}'::vector
LIMIT {top_k}
"""
stmt = text(sql)
rows = session.execute(stmt).all()
return [
{"id": r.id, "title": r.title, "source_type": r.source_type, "similarity": round(r.similarity, 4)}
for r in rows
]
def get_item_count(self) -> int:
with Session(engine) as session:
return len(session.exec(select(KnowledgeItem)).all())
def get_embedding_count(self) -> int:
with Session(engine) as session:
return len(session.exec(select(KnowledgeEmbedding)).all())
def get_grouped_items(self) -> list[dict]:
"""
按 source_type → 二级字段(author / source_detail)两层聚合,返回树形结构数据。
按 SOURCE_TYPE_ORDER 固定顺序排列,仅显示有数据的大类(count > 0)。
二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}`
例:manuscript|author|左鑫
military_report|source_detail|航空知识 2026年第1期
二级字段值为 null / 空字串 → 归入对应大类,不造空节点。
空大类(0条)不渲染。
"""
with Session(engine) as session:
items = session.exec(select(KnowledgeItem)).all()
total_count = len(items)
# 按 source_type 分组,初始化所有已知类别
type_groups: dict = {st: [] for st in self.SOURCE_TYPE_ORDER}
for item in items:
st = item.source_type or "manual"
if st not in type_groups:
type_groups[st] = []
type_groups[st].append(item)
children = []
for st in self.SOURCE_TYPE_ORDER:
st_items = type_groups.get(st, [])
# 空类别(0条)不渲染
if not st_items:
continue
secondary_field = self.SECONDARY_GROUP_FIELD.get(st)
grandchildren = []
if secondary_field is not None:
# 按二级字段分组
detail_groups: dict = {}
for item in st_items:
if secondary_field == "source_detail":
tags = item.tags or {}
sd = tags.get("source_detail") if isinstance(tags, dict) else None
field_val = sd
else:
field_val = (getattr(item, secondary_field, None) or "").strip() or None
if field_val not in detail_groups:
detail_groups[field_val] = []
detail_groups[field_val].append(item)
for sd, sd_items in detail_groups.items():
if sd is not None:
grandchildren.append({
"key": f"{st}|{secondary_field}|{sd}",
"label": f"{sd}{len(sd_items)}条)",
"count": len(sd_items),
})
children.append({
"key": st,
"label": f"{self.SOURCE_TYPE_LABEL.get(st, st)}{len(st_items)}条)",
"count": len(st_items),
"children": grandchildren,
})
return [{
"key": "all",
"label": f"全部({total_count}条)",
"count": total_count,
"children": children,
}]