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 项断言(沉淀 → 待核对 → 发布)
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""知识域状态机与"从事项生成草稿"的纯函数(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": "沉淀为知识",
|
||||
}
|
||||
|
||||
|
||||
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'))} {ACTION_LABEL[r['action']]}")
|
||||
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 ""
|
||||
Reference in New Issue
Block a user