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