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。
218 lines
7.5 KiB
Python
218 lines
7.5 KiB
Python
"""知识域状态机与"从事项生成草稿"的纯函数(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 ""
|