Files
DadaoTask/backend/app/domain/knowledge.py
T
dadao 0919c8ecd3
CI / backend (push) Canceled after 0s
fix(knowledge): 草稿的过程段写「收件箱 → 待办」,而不是两句「状态变更」
自动生成的草稿里,时间线只查了 activity.action,没带 payload,
于是每条状态变化都变成一模一样的「状态变更」,读者看不出事情怎么走的。

- services/knowledge.snapshot_source 多取 Activity.payload 一起交给渲染
- domain/knowledge 加 STATUS_LABEL 与 _describe(),把 payload 的 from/to 翻成人话
- 新增用例断言草稿里有「待办 → 进行中」「进行中 → 已完成」

66 passed。
2026-10-28 14:14:50 +08:00

218 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识域状态机与"从事项生成草稿"的纯函数(M3)。
为什么单独放一个模块:这两件事都是纯逻辑,不该缠在 HTTP 层或 ORM 里。
状态机能被 5 行测试覆盖;草稿拼装能被快照测试比对。放进 API 模块就只能靠 e2e 兜。
两个刻意的设计:
1. **草稿默认落 `draft`,不自动发布**(用户拍板)。自动生成的东西一定会有"看着像那么回事、
其实漏了关键前提"的情况,人扫一眼的成本远低于让团队按错的结论办事。
2. **草稿正文如实转述来源**,不写"建议""应该"这类推断。LLM 阶段(M4)接进来时,
`generated_by` 会从 `rule` 变成 `llm`,但"只转述、不改写结论"这条不变。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from ..errors import AppError, ErrorCode
KNOWLEDGE_TRANSITIONS: dict[str, tuple[str, ...]] = {
"draft": ("in_review", "published", "archived"),
"in_review": ("draft", "published", "archived"),
"published": ("archived",),
"archived": ("draft",),
}
KNOWLEDGE_ACTIONS: dict[str, tuple[tuple[str, ...], str]] = {
# action: (允许的前置状态, 目标状态)
"submit_review": (("draft",), "in_review"),
"withdraw": (("in_review",), "draft"),
"publish": (("draft", "in_review"), "published"),
"archive": (("draft", "in_review", "published"), "archived"),
"restore": (("archived",), "draft"),
}
# 生成草稿时给读者的一句提示,防止有人把草稿当已确认的结论转发
DRAFT_BANNER = "> 这份草稿由系统根据来源自动整理,**请先核对再发布**;发布后才会进入团队检索。"
ACTION_LABEL: dict[str, str] = {
"created": "创建",
"status_changed": "状态变更",
"promoted_to_ticket": "升级成工单",
"ticket_created": "工单创建",
"ticket_accept": "受理",
"ticket_start": "开始办理",
"ticket_submit": "提交验收",
"ticket_approve": "验收通过",
"ticket_reject": "验收驳回",
"ticket_reassign": "改派",
"ticket_cancel": "取消",
"comment_added": "评论",
"knowledge_created": "沉淀为知识",
}
# 状态流转在时间线里要写成人话:「待办 → 进行中」,光写"状态变更"等于没写
STATUS_LABEL: dict[str, str] = {
"inbox": "收件箱",
"todo": "待办",
"doing": "进行中",
"blocked": "被阻塞",
"done": "已完成",
"canceled": "已取消",
"open": "待接收",
"accepted": "已接受",
"in_progress": "办理中",
"pending_verify": "待验收",
}
def _describe(row: dict[str, Any]) -> str:
"""一条活动怎么念给读者听。"""
action = str(row.get("action") or "")
label = ACTION_LABEL[action]
payload = row.get("payload") or {}
if action == "status_changed" and isinstance(payload, dict):
src, dst = payload.get("from"), payload.get("to")
if src and dst:
from_label = STATUS_LABEL.get(str(src), str(src))
to_label = STATUS_LABEL.get(str(dst), str(dst))
return f"{from_label} → {to_label}"
if action == "knowledge_created" and isinstance(payload, dict) and payload.get("doc_type"):
return f"{label}({payload.get('doc_type')})"
return label
def target_status(action: str, current: str) -> str:
"""动作 → 目标状态。非法组合抛 7003,并把"现在还能做什么"带回去。"""
rule = KNOWLEDGE_ACTIONS.get(action)
if rule is None:
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
message=f"未知动作 {action}",
details={"action": action, "allowed_actions": sorted(KNOWLEDGE_ACTIONS)},
)
allowed_from, to_status = rule
if current not in allowed_from:
allowed_actions = [a for a, (src, _) in KNOWLEDGE_ACTIONS.items() if current in src]
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
message=f"当前状态({current})不能{_label(action)}",
details={
"action": action,
"from": current,
"allowed_from": list(allowed_from),
"allowed_actions": allowed_actions,
},
)
return to_status
def ensure_transition(current: str, target: str) -> None:
"""直接改状态(不经过动作)时的校验。"""
if target not in KNOWLEDGE_TRANSITIONS.get(current, ()):
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
message=f"不能从 {current} 直接变成 {target}",
details={
"from": current,
"to": target,
"allowed": list(KNOWLEDGE_TRANSITIONS.get(current, ())),
},
)
def sidebar_fields(target_status: str, now: datetime) -> dict[str, Any]:
"""状态跃迁时要顺手维护的时间戳。"""
if target_status == "published":
return {"published_at": now, "archived_at": None}
if target_status == "archived":
return {"archived_at": now}
return {}
def can_publish_role(role: str) -> bool:
"""发布是"团队背书",guest/member 只能提交审议。"""
return role in ("owner", "admin")
def _label(action: str) -> str:
return {
"submit_review": "提交审议",
"withdraw": "撤回审议",
"publish": "发布",
"archive": "归档",
"restore": "恢复",
}.get(action, action)
def render_draft(
*,
source_kind: str,
title: str,
body_md: str = "",
resolution_md: str = "",
created_at: datetime | None = None,
closed_at: datetime | None = None,
people: dict[str, str] | None = None,
timeline: list[dict[str, Any]] | None = None,
comments: list[dict[str, Any]] | None = None,
) -> str:
"""把一件事(任务或工单)整理成知识草稿的正文。
只做"结构化的转述":来源里有什么就写什么,没有的段落直接省略,
不补写"经验教训"这类需要人判断的话。
"""
people = people or {}
out: list[str] = [DRAFT_BANNER, ""]
when = _fmt(closed_at or created_at)
origin = "工单" if source_kind == "ticket" else "任务"
out.append(f"自动沉淀自{origin}「{title}」{(',' + when + ' 结束') if when else ''}。")
out.append("")
if people:
line = "、".join(f"{k}:{v}" for k, v in people.items() if v)
if line:
out.append(f"- {line}")
out.append("")
out.append("## 背景")
out.append("")
out.append(
body_md.strip() if body_md.strip() else "(来源里没有正文,补充一句这件事要解决什么。)"
)
out.append("")
rows = [r for r in (timeline or []) if r.get("action") in ACTION_LABEL]
if rows:
out.append("## 过程")
out.append("")
for r in rows:
out.append(f"- {_fmt(r.get('at'))} {_describe(r)}")
out.append("")
if resolution_md.strip():
out.append("## 结论")
out.append("")
out.append(resolution_md.strip())
out.append("")
said = [c for c in (comments or []) if (c.get("body") or "").strip()]
if said:
out.append("## 讨论")
out.append("")
for c in said:
who = c.get("author") or "某人"
out.append(f"- **{who}**:{c['body'].strip()}")
out.append("")
return "\n".join(out).rstrip() + "\n"
def _fmt(value: Any) -> str:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M")
return ""