Files
DadaoTask/backend/app/services/knowledge.py
T
dadao e9addeed25 feat(knowledge): 工单验收自动生成草稿,核对后发布,来源可追
- knowledge_doc 与 item 1:1,除 source_kind=manual 外必须有来源(库级约束)
- 状态机 draft/in_review/published/archived,发布要 owner/admin;非法动作回 allowed_from 与 allowed_actions
- 工单验收通过自动落 postmortem 草稿(generated_by=rule),同一来源只生成一次
- 接口:列表/详情/手记/改内容/状态动作/从事项沉淀;已发布禁改,改内容带乐观锁
- Web 知识页 + 详情抽屉;任务与工单详情里可「沉淀成知识」
- 8 个后端用例 + e2e 7 项断言(沉淀 → 待核对 → 发布)
2026-10-28 14:02:35 +08:00

137 lines
4.2 KiB
Python

"""知识域的数据库配套动作(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 ""