feat: 实现知识同步审核发布生命周期

This commit is contained in:
2026-07-11 19:04:16 +08:00
parent 73fdfed0e5
commit d2d43de6d3
6 changed files with 972 additions and 0 deletions

View File

@@ -0,0 +1,520 @@
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.knowledge import (
Knowledge,
KnowledgeCard,
KnowledgeChunk,
KnowledgeManifest,
KnowledgePublishLog,
KnowledgeReviewRecord,
KnowledgeSection,
KnowledgeSourceSnapshot,
KnowledgeSyncJob,
KnowledgeVersion,
)
from app.services.external_errors import ExternalServiceError
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
from app.services.feishu_service import _feishu_retrieval_config
PROCESSING_RULE_VERSION = "knowledge-pipeline-v1"
MAX_SECTION_CHARS = 2800
MAX_CHUNK_CHARS = 650
@dataclass(frozen=True)
class ParsedSection:
title: str
content: str
source_start: int
source_end: int
class KnowledgePipelineService:
@classmethod
async def synchronize(
cls,
db: Session,
knowledge: Knowledge,
*,
admin_id: int,
mode: str,
) -> KnowledgeSyncJob:
if mode not in {"review", "replace"}:
raise ValueError("同步模式必须为 review 或 replace")
job = KnowledgeSyncJob(
knowledge_id=knowledge.id,
mode=mode,
status="running",
stage="fetching",
progress=5,
requested_by=admin_id,
started_at=_now(),
)
db.add(job)
db.commit()
db.refresh(job)
try:
config = _feishu_retrieval_config(db)
scope = _scope_from_knowledge(knowledge)
documents = await AsyncFeishuKnowledgeService._load_documents(scope, config)
if not documents:
raise ExternalServiceError("飞书文章内容为空或无法读取", provider="feishu")
source_title, source_content, _ = documents[0]
if len(documents) > 1:
source_content = "\n\n".join(f"# {title}\n{content}" for title, content, _ in documents)
source_content = source_content.strip()
if not source_content:
raise ExternalServiceError("飞书文章内容为空", provider="feishu")
cls._set_job(db, job, "snapshot", 20)
digest = _hash(source_content)
latest_snapshot = db.scalar(
select(KnowledgeSourceSnapshot)
.where(KnowledgeSourceSnapshot.knowledge_id == knowledge.id)
.order_by(KnowledgeSourceSnapshot.id.desc())
.limit(1)
)
if latest_snapshot and latest_snapshot.content_hash == digest:
knowledge.source_status = "normal"
knowledge.source_error = None
knowledge.source_title = source_title
knowledge.last_synced_at = _now()
job.status = "unchanged"
job.stage = "completed"
job.progress = 100
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
job.finished_at = _now()
db.add_all([knowledge, job])
db.commit()
return job
snapshot = KnowledgeSourceSnapshot(
knowledge_id=knowledge.id,
sync_job_id=job.id,
source_title=source_title,
source_content=source_content,
content_hash=digest,
source_identifier=f"{knowledge.feishu_space_id}/{knowledge.feishu_node_id}",
fetched_at=_now(),
created_by=admin_id,
)
db.add(snapshot)
db.flush()
version_no = int(
db.scalar(select(func.coalesce(func.max(KnowledgeVersion.version_no), 0)).where(
KnowledgeVersion.knowledge_id == knowledge.id
))
or 0
) + 1
version = KnowledgeVersion(
knowledge_id=knowledge.id,
snapshot_id=snapshot.id,
version_no=version_no,
status="processing",
quality_status="pending",
processing_rule_version=PROCESSING_RULE_VERSION,
created_by=admin_id,
)
db.add(version)
db.flush()
cls._set_job(db, job, "parsing", 40)
sections = parse_sections(source_content)
section_rows: list[KnowledgeSection] = []
chunk_count = 0
for index, parsed in enumerate(sections, start=1):
section = KnowledgeSection(
knowledge_id=knowledge.id,
version_id=version.id,
section_key=f"S{index:04d}",
title=parsed.title,
content=parsed.content,
source_start=parsed.source_start,
source_end=parsed.source_end,
sort_order=index,
content_hash=_hash(parsed.content),
)
db.add(section)
db.flush()
section_rows.append(section)
for chunk_index, (chunk_content, start, end) in enumerate(split_chunks(parsed), start=1):
terms = extract_terms(f"{parsed.title} {chunk_content}")
db.add(
KnowledgeChunk(
knowledge_id=knowledge.id,
version_id=version.id,
section_id=section.id,
title=parsed.title,
content=chunk_content,
normalized_text=" ".join(terms),
keywords=json.dumps(terms[:30], ensure_ascii=False),
synonyms=json.dumps(expand_synonyms(terms[:20]), ensure_ascii=False),
source_start=start,
source_end=end,
sort_order=chunk_index,
content_hash=_hash(chunk_content),
)
)
chunk_count += 1
cls._set_job(db, job, "cards", 65)
for section in section_rows:
terms = extract_terms(f"{section.title} {section.content}")
db.add(
KnowledgeCard(
knowledge_id=knowledge.id,
version_id=version.id,
section_id=section.id,
title=section.title,
summary=_summary(section.content, 180),
core_conclusion=_summary(section.content, 260),
applicable_questions="".join(terms[:8]) or section.title,
inapplicable_questions="与本章节主题无直接关系的问题",
keywords=json.dumps(terms[:30], ensure_ascii=False),
synonyms=json.dumps(expand_synonyms(terms[:20]), ensure_ascii=False),
entities=json.dumps(terms[:12], ensure_ascii=False),
risk_level=detect_risk_level(section.content),
source_range=f"{section.source_start}-{section.source_end}",
content_hash=section.content_hash,
generation_model="deterministic-v1",
generation_rule_version=PROCESSING_RULE_VERSION,
review_status="pending",
)
)
manifest_data = build_manifest(source_title, source_content, section_rows)
previous_manifest = None
if knowledge.current_version_id:
previous_manifest = db.scalar(
select(KnowledgeManifest).where(KnowledgeManifest.version_id == knowledge.current_version_id)
)
change_level = manifest_change_level(previous_manifest, manifest_data)
manifest = KnowledgeManifest(
knowledge_id=knowledge.id,
version_id=version.id,
purpose=manifest_data["purpose"],
applicable_questions=manifest_data["applicableQuestions"],
inapplicable_questions=manifest_data["inapplicableQuestions"],
core_topics=manifest_data["coreTopics"],
entities=manifest_data["entities"],
boundaries=manifest_data["boundaries"],
content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)),
change_level=change_level,
confirmed=1 if previous_manifest and change_level == "minor" else 0,
)
db.add(manifest)
cls._set_job(db, job, "quality", 82)
quality = quality_check(source_content, sections, chunk_count)
version.quality_status = "passed" if not quality["blocking"] else "blocked"
version.quality_report = json.dumps(quality, ensure_ascii=False)
version.change_summary = json.dumps(
{"sections": len(sections), "chunks": chunk_count, "manifestChange": change_level},
ensure_ascii=False,
)
version.status = "pending_review"
knowledge.source_title = source_title
knowledge.source_status = "normal"
knowledge.source_error = None
knowledge.pending_version_id = version.id
knowledge.last_synced_at = _now()
knowledge.migration_status = "pending_review"
job.status = "pending_review"
job.stage = "completed"
job.progress = 100
job.result_summary = json.dumps(
{"versionId": version.id, "versionNo": version.version_no, **quality}, ensure_ascii=False
)
job.finished_at = _now()
db.add_all([knowledge, version, job])
db.commit()
return job
except Exception as exc:
db.rollback()
stored_job = db.get(KnowledgeSyncJob, job.id)
stored_knowledge = db.get(Knowledge, knowledge.id)
if stored_job:
stored_job.status = "failed"
stored_job.stage = "failed"
stored_job.source_error = str(exc)
stored_job.finished_at = _now()
db.add(stored_job)
if stored_knowledge:
stored_knowledge.source_status = "error"
stored_knowledge.source_error = str(exc)
stored_knowledge.migration_status = "failed"
db.add(stored_knowledge)
db.commit()
raise
@staticmethod
def confirm_manifest(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int) -> None:
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
if manifest is None:
raise ValueError("该版本没有知识库说明卡")
manifest.confirmed = 1
manifest.confirmed_by = admin_id
manifest.confirmed_at = _now()
if knowledge.pending_version_id == version.id:
knowledge.manifest_confirmed = 1
db.add_all([manifest, knowledge])
@staticmethod
def review_version(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, action: str, reason: str | None) -> None:
if action not in {"approve", "reject"}:
raise ValueError("审核动作不正确")
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == version.id)).all()
review_status = "approved" if action == "approve" else "rejected"
for card in cards:
card.review_status = review_status
db.add(card)
version.status = "approved" if action == "approve" else "rejected"
db.add_all(
[
version,
KnowledgeReviewRecord(
knowledge_id=knowledge.id,
version_id=version.id,
action=action,
reason=reason,
reviewed_by=admin_id,
),
]
)
@staticmethod
def publish_version(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, keep_open: bool) -> None:
if version.status != "approved":
raise ValueError("只有审核通过的版本可以发布")
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
if manifest is None or manifest.confirmed != 1:
raise ValueError("知识库说明卡尚未确认")
previous_version_id = knowledge.current_version_id
if previous_version_id:
previous = db.get(KnowledgeVersion, previous_version_id)
if previous:
previous.status = "superseded"
db.add(previous)
version.status = "published"
version.published_by = admin_id
version.published_at = _now()
knowledge.current_version_id = version.id
knowledge.pending_version_id = None
knowledge.manifest_confirmed = 1
knowledge.last_published_at = version.published_at
knowledge.status = 1 if keep_open else 0
knowledge.migration_status = "completed"
db.add_all(
[
version,
knowledge,
KnowledgePublishLog(
knowledge_id=knowledge.id,
from_version_id=previous_version_id,
to_version_id=version.id,
action="publish",
keep_open=knowledge.status,
operated_by=admin_id,
),
]
)
@staticmethod
def rollback(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, keep_open: bool) -> None:
if version.status not in {"published", "superseded"}:
raise ValueError("只能回滚到历史正式版本")
previous_id = knowledge.current_version_id
knowledge.current_version_id = version.id
knowledge.status = 1 if keep_open else 0
knowledge.last_published_at = _now()
db.add_all(
[
knowledge,
KnowledgePublishLog(
knowledge_id=knowledge.id,
from_version_id=previous_id,
to_version_id=version.id,
action="rollback",
keep_open=knowledge.status,
operated_by=admin_id,
),
]
)
@staticmethod
def _set_job(db: Session, job: KnowledgeSyncJob, stage: str, progress: int) -> None:
job.stage = stage
job.progress = progress
db.add(job)
db.flush()
def parse_sections(content: str) -> list[ParsedSection]:
heading = re.compile(r"(?m)^(#{1,6}\s+.+|[^\n]{1,80}\n[-=]{3,})$")
matches = list(heading.finditer(content))
boundaries: list[tuple[int, str]] = []
for match in matches:
raw = match.group(0).splitlines()[0].lstrip("#").strip()
boundaries.append((match.start(), raw))
if not boundaries or boundaries[0][0] > 0:
boundaries.insert(0, (0, "正文"))
sections: list[ParsedSection] = []
for index, (start, title) in enumerate(boundaries):
end = boundaries[index + 1][0] if index + 1 < len(boundaries) else len(content)
block = content[start:end].strip()
if not block:
continue
cursor = 0
while len(block) - cursor > MAX_SECTION_CHARS:
cut = block.rfind("\n\n", cursor, cursor + MAX_SECTION_CHARS)
if cut <= cursor:
cut = cursor + MAX_SECTION_CHARS
part = block[cursor:cut].strip()
sections.append(ParsedSection(title, part, start + cursor, start + cut))
cursor = cut
tail = block[cursor:].strip()
if tail:
sections.append(ParsedSection(title, tail, start + cursor, end))
return sections or [ParsedSection("正文", content, 0, len(content))]
def split_chunks(section: ParsedSection) -> list[tuple[str, int, int]]:
chunks: list[tuple[str, int, int]] = []
cursor = 0
while cursor < len(section.content):
end = min(len(section.content), cursor + MAX_CHUNK_CHARS)
if end < len(section.content):
boundary = max(section.content.rfind("\n\n", cursor, end), section.content.rfind("", cursor, end))
if boundary > cursor + 120:
end = boundary + 1
text = section.content[cursor:end].strip()
if text:
chunks.append((text, section.source_start + cursor, section.source_start + end))
cursor = end
return chunks
def extract_terms(text: str) -> list[str]:
normalized = re.sub(r"\s+", "", text.lower())
ascii_words = re.findall(r"[a-z0-9_]{2,}", normalized)
chinese_runs = re.findall(r"[\u4e00-\u9fff]+", normalized)
terms = list(ascii_words)
for run in chinese_runs:
if len(run) <= 4:
terms.append(run)
for size in (2, 3, 4):
terms.extend(run[i : i + size] for i in range(max(0, len(run) - size + 1)))
return list(dict.fromkeys(term for term in terms if term))
_SYNONYMS = {
"孩子": ["小孩", "学生", "子女"],
"家长": ["父母", "妈妈", "爸爸"],
"学习": ["功课", "学业", "上课"],
"焦虑": ["担心", "紧张", "害怕"],
"沟通": ["交流", "对话", "聊天"],
"课程": ["训练营", "大本营", "课堂"],
}
def expand_synonyms(terms: list[str]) -> list[str]:
values: list[str] = []
for term in terms:
values.extend(_SYNONYMS.get(term, []))
return list(dict.fromkeys(values))
def build_manifest(title: str, content: str, sections: list[KnowledgeSection]) -> dict[str, str]:
fields = {}
for label, key in [
("知识库用途", "purpose"),
("适用问题", "applicableQuestions"),
("不适用问题", "inapplicableQuestions"),
("核心主题", "coreTopics"),
]:
match = re.search(rf"{label}[:]\s*([^\n]+)", content)
if match:
fields[key] = match.group(1).strip()
topics = "".join(list(dict.fromkeys(section.title for section in sections))[:12])
return {
"purpose": fields.get("purpose", f"提供与《{title}》相关的课程、答疑和业务知识"),
"applicableQuestions": fields.get("applicableQuestions", topics or title),
"inapplicableQuestions": fields.get("inapplicableQuestions", "与本文主题无直接关系的一般问题"),
"coreTopics": fields.get("coreTopics", topics or title),
"entities": topics,
"boundaries": "只能围绕具体问题提供必要解释,不输出整篇原文或大段连续课程资料。",
}
def manifest_change_level(previous: KnowledgeManifest | None, current: dict[str, str]) -> str:
if previous is None:
return "major"
major_fields = ["purpose", "applicableQuestions", "inapplicableQuestions", "coreTopics"]
old = [previous.purpose, previous.applicable_questions, previous.inapplicable_questions, previous.core_topics]
new = [current[field] for field in major_fields]
return "minor" if all(_similar(left, right) >= 0.72 for left, right in zip(old, new, strict=True)) else "major"
def quality_check(content: str, sections: list[ParsedSection], chunk_count: int) -> dict:
duplicate_hashes = [_hash(item.content) for item in sections]
issues: list[dict[str, str]] = []
if len(content) < 50:
issues.append({"level": "blocking", "code": "content_too_short", "message": "源文章内容过短"})
if not sections or chunk_count == 0:
issues.append({"level": "blocking", "code": "parse_empty", "message": "未生成章节或切片"})
if len(set(duplicate_hashes)) != len(duplicate_hashes):
issues.append({"level": "warning", "code": "duplicate_sections", "message": "存在重复章节"})
high_risk = detect_risk_level(content) == "high"
if high_risk:
issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题,需要重点审核"})
return {
"blocking": any(item["level"] == "blocking" for item in issues),
"issues": issues,
"sectionCount": len(sections),
"chunkCount": chunk_count,
"highRisk": high_risk,
}
def detect_risk_level(text: str) -> str:
high = ("自杀", "自伤", "伤人", "违法", "医疗", "诊断", "药物", "投资", "法律")
return "high" if any(word in text for word in high) else "normal"
def _summary(text: str, limit: int) -> str:
value = re.sub(r"\s+", " ", text).strip()
return value if len(value) <= limit else value[:limit].rstrip() + ""
def _hash(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _similar(left: str, right: str) -> float:
left_terms, right_terms = set(extract_terms(left)), set(extract_terms(right))
return len(left_terms & right_terms) / max(1, len(left_terms | right_terms))
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def _scope_from_knowledge(knowledge: Knowledge):
from app.services.knowledge_service import KnowledgeScope
return KnowledgeScope(
id=knowledge.id,
name=knowledge.name,
feishu_space_id=knowledge.feishu_space_id,
feishu_node_id=knowledge.feishu_node_id,
)