"""知识域的数据库配套动作(M3)。 放在 services 而不是 api:两个入口都要用——用户手动点"沉淀成知识", 以及工单验收通过时自动落一份草稿。逻辑只有一份,行为才不会分叉。 """ from __future__ import annotations import uuid from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ..domain.knowledge import render_draft from ..errors import AppError, ErrorCode from ..models import AppUser from ..models_knowledge import KnowledgeDoc from ..models_task import Activity, Comment, Item async def snapshot_source(session: AsyncSession, ws_id: uuid.UUID, item: Item) -> dict[str, Any]: """把来源事项的"人、时间线、讨论"取成一份快照,供 render_draft 使用。""" nicknames = dict( (await session.execute(select(AppUser.id, AppUser.nickname))).all() # type: ignore[arg-type] ) timeline = ( await session.execute( select(Activity.action, Activity.created_at) .where(Activity.workspace_id == ws_id, Activity.item_id == item.id) .order_by(Activity.created_at) ) ).all() comments = ( await session.execute( select(Comment.body_md, Comment.author_id, Comment.created_at) .where( Comment.workspace_id == ws_id, Comment.item_id == item.id, Comment.deleted_at.is_(None), ) .order_by(Comment.created_at) ) ).all() return { "people": { "提出人": nicknames.get(item.creator_id, ""), "办理人": nicknames.get(item.assignee_id, "") if item.assignee_id else "", }, "timeline": [{"action": a, "at": at} for a, at in timeline], "comments": [ {"body": body, "author": nicknames.get(author_id, ""), "at": at} for body, author_id, at in comments ], } async def find_doc_by_source( session: AsyncSession, ws_id: uuid.UUID, source_item_id: uuid.UUID ) -> KnowledgeDoc | None: return ( await session.execute( select(KnowledgeDoc).where( KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.source_item_id == source_item_id, ) ) ).scalar_one_or_none() async def create_draft_from_source( session: AsyncSession, *, ws_id: uuid.UUID, source: Item, author_id: uuid.UUID, generated_by: str = "rule", resolution_md: str = "", doc_type: str = "note", title: str | None = None, ) -> tuple[Item, KnowledgeDoc]: """从一条任务/工单生成知识草稿(draft)。同一个来源只沉淀一次。""" existing = await find_doc_by_source(session, ws_id, source.id) if existing is not None: raise AppError( ErrorCode.KNOWLEDGE_ALREADY_EXISTS, message="这件事已经沉淀过一份知识了", details={"knowledge_id": str(existing.item_id)}, ) snap = await snapshot_source(session, ws_id, source) body = render_draft( source_kind=source.kind, title=source.title, body_md=source.body_md, resolution_md=resolution_md, created_at=source.created_at, closed_at=source.completed_at, people=snap["people"], timeline=snap["timeline"], comments=snap["comments"], ) doc_item = Item( workspace_id=ws_id, kind="knowledge", title=title or source.title, body_md=body, status="draft", priority=0, creator_id=author_id, ) session.add(doc_item) await session.flush() doc = KnowledgeDoc( item_id=doc_item.id, workspace_id=ws_id, author_id=author_id, source_item_id=source.id, source_kind="ticket" if source.kind == "ticket" else "task", doc_type=doc_type, status_hint="draft", summary=_first_line(resolution_md) or source.title, generated_by=generated_by, ) session.add(doc) await session.flush() return doc_item, doc def _first_line(text: str) -> str: for line in text.strip().splitlines(): if line.strip(): return line.strip()[:200] return ""