CI / backend (push) Canceled after 0s
自动生成的草稿里,时间线只查了 activity.action,没带 payload, 于是每条状态变化都变成一模一样的「状态变更」,读者看不出事情怎么走的。 - services/knowledge.snapshot_source 多取 Activity.payload 一起交给渲染 - domain/knowledge 加 STATUS_LABEL 与 _describe(),把 payload 的 from/to 翻成人话 - 新增用例断言草稿里有「待办 → 进行中」「进行中 → 已完成」 66 passed。
137 lines
4.2 KiB
Python
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, Activity.payload)
|
|
.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, "payload": p or {}} for a, at, p 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 ""
|