From e9addeed2509b2318800dcacbb49e6373f773015 Mon Sep 17 00:00:00 2001 From: dadao Date: Wed, 28 Oct 2026 14:02:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20=E5=B7=A5=E5=8D=95=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=EF=BC=8C=E6=A0=B8=E5=AF=B9=E5=90=8E=E5=8F=91=E5=B8=83=EF=BC=8C?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E5=8F=AF=E8=BF=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 项断言(沉淀 → 待核对 → 发布) --- backend/alembic/versions/0004_knowledge.py | 118 ++++++ backend/app/api/knowledge.py | 450 +++++++++++++++++++++ backend/app/api/tickets.py | 40 ++ backend/app/domain/knowledge.py | 187 +++++++++ backend/app/errors.py | 12 + backend/app/main.py | 3 +- backend/app/models.py | 2 + backend/app/models_knowledge.py | 79 ++++ backend/app/services/__init__.py | 0 backend/app/services/knowledge.py | 136 +++++++ backend/tests/test_knowledge.py | 342 ++++++++++++++++ web/e2e/knowledge.mjs | 135 +++++++ web/src/App.tsx | 2 + web/src/components/TaskDetail.tsx | 33 ++ web/src/components/WorkspaceNav.tsx | 1 + web/src/lib/types.ts | 28 ++ web/src/pages/Knowledge.tsx | 390 ++++++++++++++++++ 17 files changed, 1957 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0004_knowledge.py create mode 100644 backend/app/api/knowledge.py create mode 100644 backend/app/domain/knowledge.py create mode 100644 backend/app/models_knowledge.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/knowledge.py create mode 100644 backend/tests/test_knowledge.py create mode 100644 web/e2e/knowledge.mjs create mode 100644 web/src/pages/Knowledge.tsx diff --git a/backend/alembic/versions/0004_knowledge.py b/backend/alembic/versions/0004_knowledge.py new file mode 100644 index 0000000..897bba3 --- /dev/null +++ b/backend/alembic/versions/0004_knowledge.py @@ -0,0 +1,118 @@ +"""M3 知识域:knowledge_doc + 扩宽 item.status(加知识四态)。 + +三个需要注意的点: + +1. **item.status 的 CHECK 再扩一次**。0003 里是"任务 ∪ 工单",知识的 + `draft/in_review/published/archived` 会被直接拦下。这里改为三者并集; + "哪种 kind 只能用哪组状态"由 `app/domain/*.py` 的状态机保证,不靠数据库 + (CHECK 不能跨行判断 kind 与 status 的组合)。 +2. **知识的来源写在数据库里**。`source_kind <> 'manual'` 时 `source_item_id` 必填。 + 这条 CHECK 是 D3 的物理落点:LLM 自动归档出来的知识若没有来源,用户无法核对, + 等于往库里塞断言。 +3. **downgrade 会删掉所有知识**(kind='knowledge' 的 item 及其扩展),原因与 0003 相同: + 旧约束容纳不下知识状态,不回删就回不到 0003。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0004" +down_revision: str | None = "0003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +APP_ROLE = "dadao_app" + +TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",) + +TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled") +TICKET_STATUSES = ("open", "accepted", "in_progress", "pending_verify", "done", "canceled") +KNOWLEDGE_STATUSES = ("draft", "in_review", "published", "archived") + + +def upgrade() -> None: + # 1) item.status 放宽到 任务 ∪ 工单 ∪ 知识 + op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status") + statuses = ",".join( + f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES, *KNOWLEDGE_STATUSES) + ) + op.execute(f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))") + + # 2) knowledge_doc + op.create_table( + "knowledge_doc", + sa.Column( + "item_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("item.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "workspace_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("workspace.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "author_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("app_user.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "source_item_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("item.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("source_kind", sa.String(16), nullable=False, server_default="manual"), + sa.Column("doc_type", sa.String(16), nullable=False, server_default="note"), + sa.Column("status_hint", sa.String(16), nullable=False, server_default="draft"), + sa.Column("summary", sa.Text(), nullable=False, server_default=""), + sa.Column("generated_by", sa.String(16), nullable=False, server_default="human"), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("chunk_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + sa.CheckConstraint( + "source_kind = 'manual' OR source_item_id IS NOT NULL", + name="ck_knowledge_source_required", + ), + sa.CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"), + ) + op.create_index("ix_knowledge_ws_status", "knowledge_doc", ["workspace_id", "status_hint"]) + op.create_index("ix_knowledge_ws_source", "knowledge_doc", ["workspace_id", "source_item_id"]) + + # 3) RLS + 授权 + op.execute("ALTER TABLE knowledge_doc ENABLE ROW LEVEL SECURITY") + op.execute( + "CREATE POLICY knowledge_doc_isolation ON knowledge_doc " + "USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())" + ) + op.execute( + f"GRANT SELECT, INSERT, UPDATE, DELETE ON {', '.join(TENANT_TABLES_0004)} TO {APP_ROLE}" + ) + + +def downgrade() -> None: + # 旧约束容纳不下知识状态:先清掉知识 item,才能回到 0003 的约束 + op.execute("DELETE FROM item WHERE kind = 'knowledge'") + op.execute("DROP POLICY IF EXISTS knowledge_doc_isolation ON knowledge_doc") + op.drop_index("ix_knowledge_ws_source", table_name="knowledge_doc") + op.drop_index("ix_knowledge_ws_status", table_name="knowledge_doc") + op.drop_table("knowledge_doc") + + op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status") + statuses = ",".join(f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES)) + op.execute(f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))") diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py new file mode 100644 index 0000000..35deaf8 --- /dev/null +++ b/backend/app/api/knowledge.py @@ -0,0 +1,450 @@ +"""知识域接口(M3):草稿、审议、发布、归档,以及"从事项沉淀"。 + +一条知识的一生: + + 工单验收通过(或你手动点"沉淀成知识") + -> draft 草稿:系统整理好了,等你核对 + -> in_review 提交审议:让同事看一遍(可选) + -> published 发布:进团队检索、能被问答引用 + -> archived 归档:不删,退出检索(历史结论不该消失) + +为什么要 `draft` 这一步:自动整理的东西一定有"看着像那么回事、其实漏了前提"的情况, +让人扫一眼的成本远低于让整个团队按错的结论办事。发布权收紧到 owner/admin 也是同理—— +发布等于团队背书。 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Annotated, Any + +from fastapi import APIRouter, Query +from pydantic import BaseModel, Field +from sqlalchemy import Select, and_, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from ..audit import record as audit_record +from ..deps import MemberDep, SessionDep +from ..domain.knowledge import can_publish_role, sidebar_fields, target_status +from ..errors import AppError, ErrorCode +from ..models_knowledge import DOC_TYPES, KNOWLEDGE_STATUSES, KnowledgeDoc +from ..models_task import Activity, Item +from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok +from ..serializers import item_out, tags_of +from ..services.knowledge import create_draft_from_source, find_doc_by_source + +router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"]) + + +# ── 入参 ──────────────────────────────────────────────────────────────── +class KnowledgeIn(BaseModel): + title: str = Field(min_length=1, max_length=200) + body_md: str = "" + summary: str = Field(default="", max_length=2000) + doc_type: str = "note" + source_item_id: uuid.UUID | None = None + tag_names: list[str] = Field(default_factory=list) + + +class KnowledgePatch(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=200) + body_md: str | None = None + summary: str | None = None + doc_type: str | None = None + tag_names: list[str] | None = None + version: int + + +class StatusActionIn(BaseModel): + action: str + version: int + note: str = "" + + +# ── 内部工具 ──────────────────────────────────────────────────────────── +async def _load_doc( + session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID +) -> tuple[Item, KnowledgeDoc]: + item = await session.get(Item, item_id) + doc = await session.get(KnowledgeDoc, item_id) + if item is None or doc is None or item.workspace_id != ws_id or item.kind != "knowledge": + raise AppError(ErrorCode.KNOWLEDGE_NOT_FOUND, "这份知识不存在") + return item, doc + + +async def _log( + session: AsyncSession, item: Item, actor_id: uuid.UUID, action: str, payload: dict | None = None +) -> None: + session.add( + Activity( + workspace_id=item.workspace_id, + item_id=item.id, + actor_id=actor_id, + action=action, + payload=payload or {}, + ) + ) + + +async def _source_out( + session: AsyncSession, ws_id: uuid.UUID, source_item_id: uuid.UUID | None +) -> dict[str, Any] | None: + if source_item_id is None: + return None + source = await session.get(Item, source_item_id) + if source is None or source.workspace_id != ws_id: + return None + return { + "id": str(source.id), + "kind": source.kind, + "title": source.title, + "status": source.status, + } + + +def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]: + return { + "doc_type": doc.doc_type, + "summary": doc.summary, + "status_hint": doc.status_hint, + "source_kind": doc.source_kind, + "source_item_id": str(doc.source_item_id) if doc.source_item_id else None, + "generated_by": doc.generated_by, + "published_at": doc.published_at, + "archived_at": doc.archived_at, + "chunk_count": doc.chunk_count, + "author_id": str(doc.author_id), + } + + +async def _knowledge_out( + session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc +) -> dict[str, Any]: + tags = (await tags_of(session, ws_id, [item.id])).get(item.id, []) + data = item_out(item, tags) + data["knowledge"] = _doc_out(doc) + data["source"] = await _source_out(session, ws_id, doc.source_item_id) + return data + + +async def _set_tags_for( + session: AsyncSession, ws_id: uuid.UUID, item: Item, names: list[str] +) -> None: + from .items import _set_tags + + await _set_tags(session, ws_id, item, names) + + +# ── 列表 / 详情 ───────────────────────────────────────────────────────── +@router.get("/knowledge", summary="知识列表") +async def list_knowledge( + ctx: MemberDep, + session: SessionDep, + status: Annotated[list[str] | None, Query()] = None, + doc_type: Annotated[str | None, Query()] = None, + source_item_id: Annotated[uuid.UUID | None, Query()] = None, + q: Annotated[str | None, Query(max_length=100)] = None, + limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30, + cursor: Annotated[str | None, Query(description="上一页返回的 next_cursor")] = None, +) -> dict: + ws_id = ctx.ws_id + stmt: Select = ( + select(Item, KnowledgeDoc) + .join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id) + .where(Item.workspace_id == ws_id, Item.kind == "knowledge", Item.deleted_at.is_(None)) + ) + wanted = [s for s in (status or []) if s in KNOWLEDGE_STATUSES] + if wanted: + stmt = stmt.where(KnowledgeDoc.status_hint.in_(wanted)) + if doc_type: + stmt = stmt.where(KnowledgeDoc.doc_type == doc_type) + if source_item_id: + stmt = stmt.where(KnowledgeDoc.source_item_id == source_item_id) + if q: + like = f"%{q.strip()}%" + stmt = stmt.where( + or_(Item.title.ilike(like), Item.body_md.ilike(like), KnowledgeDoc.summary.ilike(like)) + ) + if cursor: + cur = decode_cursor(cursor) + stmt = stmt.where( + or_( + Item.updated_at < cur.created_at, + and_(Item.updated_at == cur.created_at, Item.id < cur.item_id), + ) + ) + + limit = clamp_limit(limit) + rows = ( + await session.execute( + stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1) + ) + ).all() + has_more = len(rows) > limit + rows = rows[:limit] + tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows]) + + items = [] + for item, doc in rows: + data = item_out(item, tag_map.get(item.id, [])) + data["knowledge"] = _doc_out(doc) + data["source"] = await _source_out(session, ws_id, doc.source_item_id) + items.append(data) + + next_cursor = ( + encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None + ) + return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more}) + + +@router.get("/knowledge/{item_id}", summary="知识详情") +async def get_knowledge(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict: + item, doc = await _load_doc(session, ctx.ws_id, item_id) + data = await _knowledge_out(session, ctx.ws_id, item, doc) + events = ( + await session.execute( + select(Activity) + .where(Activity.workspace_id == ctx.ws_id, Activity.item_id == item.id) + .order_by(Activity.created_at.desc()) + .limit(50) + ) + ).scalars() + data["activities"] = [ + { + "action": e.action, + "payload": e.payload, + "actor_id": str(e.actor_id) if e.actor_id else None, + "created_at": e.created_at, + } + for e in events + ] + return ok(data) + + +# ── 创建 / 修改 ───────────────────────────────────────────────────────── +@router.post("/knowledge", summary="新建知识(手记或引用来源)") +async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDep) -> dict: + ws_id = ctx.ws_id + if body.doc_type not in DOC_TYPES: + raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}") + + source: Item | None = None + if body.source_item_id is not None: + source = await session.get(Item, body.source_item_id) + if source is None or source.workspace_id != ws_id or source.kind not in ("task", "ticket"): + raise AppError( + ErrorCode.KNOWLEDGE_SOURCE_REQUIRED, + "来源必须是与本知识同一工作区的任务或工单", + ) + + item = Item( + workspace_id=ws_id, + kind="knowledge", + title=body.title.strip(), + body_md=body.body_md, + status="draft", + creator_id=ctx.user.id, + ) + session.add(item) + await session.flush() + + doc = KnowledgeDoc( + item_id=item.id, + workspace_id=ws_id, + author_id=ctx.user.id, + source_item_id=source.id if source else None, + source_kind="manual" if source is None else source.kind, + doc_type=body.doc_type, + status_hint="draft", + summary=body.summary, + generated_by="human", + ) + session.add(doc) + await session.flush() + await _log(session, item, ctx.user.id, "knowledge_created", {"generated_by": "human"}) + await _set_tags_for(session, ws_id, item, body.tag_names) + await audit_record( + session, + workspace_id=ws_id, + actor_user_id=ctx.user.id, + action="knowledge.create", + target_type="item", + target_id=str(item.id), + ) + return ok(await _knowledge_out(session, ws_id, item, doc)) + + +@router.patch("/knowledge/{item_id}", summary="修改知识内容") +async def patch_knowledge( + item_id: uuid.UUID, body: KnowledgePatch, ctx: MemberDep, session: SessionDep +) -> dict: + ws_id = ctx.ws_id + item, doc = await _load_doc(session, ws_id, item_id) + if item.status == "published": + raise AppError( + ErrorCode.KNOWLEDGE_STATUS_INVALID, + "已发布的知识不能直接改内容,先撤回成草稿", + details={"from": item.status, "allowed_actions": ["archive"]}, + ) + if body.doc_type is not None and body.doc_type not in DOC_TYPES: + raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}") + + values: dict[str, Any] = {} + if body.title is not None: + values["title"] = body.title.strip() + if body.body_md is not None: + values["body_md"] = body.body_md + if body.summary is not None: + values["summary"] = body.summary + if body.doc_type is not None: + values["doc_type"] = body.doc_type + + if values: + values["updated_at"] = datetime.now(timezone.utc) + result = await session.execute( + update(Item) + .where( + Item.id == item.id, + Item.workspace_id == ws_id, + Item.version == body.version, + Item.deleted_at.is_(None), + ) + .values(version=Item.version + 1, **values) + ) + if result.rowcount == 0: + await session.refresh(item) + raise AppError( + ErrorCode.VERSION_CONFLICT, + "这条内容刚被别人改过", + details={"current_version": item.version}, + ) + await session.refresh(item) + if body.doc_type is not None: + doc.doc_type = body.doc_type + if body.summary is not None: + doc.summary = body.summary + if body.tag_names is not None: + await _set_tags_for(session, ws_id, item, body.tag_names) + await session.flush() + return ok(await _knowledge_out(session, ws_id, item, doc)) + + +# ── 状态动作 ──────────────────────────────────────────────────────────── +@router.post("/knowledge/{item_id}/status", summary="提交审议 / 发布 / 归档 / 恢复") +async def change_status( + item_id: uuid.UUID, body: StatusActionIn, ctx: MemberDep, session: SessionDep +) -> dict: + ws_id = ctx.ws_id + item, doc = await _load_doc(session, ws_id, item_id) + to_status = target_status(body.action, item.status) + + # 发布等于团队背书;归档/恢复作者自己就能做 + if body.action == "publish" and not can_publish_role(ctx.role): + raise AppError( + ErrorCode.KNOWLEDGE_FORBIDDEN, + "只有管理员可以发布知识", + details={"role": ctx.role}, + ) + + now = datetime.now(timezone.utc) + result = await session.execute( + update(Item) + .where( + Item.id == item.id, + Item.workspace_id == ws_id, + Item.version == body.version, + Item.deleted_at.is_(None), + ) + .values(version=Item.version + 1, status=to_status, updated_at=now) + ) + if result.rowcount == 0: + await session.refresh(item) + raise AppError( + ErrorCode.VERSION_CONFLICT, + "这条内容刚被别人改过", + details={"current_version": item.version}, + ) + await session.refresh(item) + # 发布时间/归档时间记在 knowledge_doc 上(item 只有 status) + doc.status_hint = to_status + stamps = sidebar_fields(to_status, now) + if "published_at" in stamps: + doc.published_at = stamps["published_at"] + doc.archived_at = None + if "archived_at" in stamps: + doc.archived_at = stamps["archived_at"] + await _log( + session, + item, + ctx.user.id, + "knowledge_status_changed", + {"action": body.action, "to": to_status, "note": body.note}, + ) + await audit_record( + session, + workspace_id=ws_id, + actor_user_id=ctx.user.id, + action=f"knowledge.{body.action}", + target_type="item", + target_id=str(item.id), + payload={"to": to_status}, + ) + await session.flush() + return ok(await _knowledge_out(session, ws_id, item, doc)) + + +# ── 从任务/工单沉淀 ───────────────────────────────────────────────────── +@router.post("/items/{item_id}/knowledge", summary="把这件事沉淀成知识草稿") +async def promote_to_knowledge( + item_id: uuid.UUID, + ctx: MemberDep, + session: SessionDep, + doc_type: Annotated[str, Query()] = "postmortem", +) -> dict: + ws_id = ctx.ws_id + source = await session.get(Item, item_id) + if source is None or source.workspace_id != ws_id or source.kind not in ("task", "ticket"): + raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在") + if doc_type not in DOC_TYPES: + raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{doc_type}") + + existing = await find_doc_by_source(session, ws_id, source.id) + if existing is not None: + raise AppError( + ErrorCode.KNOWLEDGE_ALREADY_EXISTS, + "这件事已经沉淀过了", + details={"knowledge_id": str(existing.item_id)}, + ) + + resolution = "" + if source.kind == "ticket": + from ..models_ticket import TicketExt + + ext = await session.get(TicketExt, source.id) + if ext is not None and ext.resolution_md: + resolution = ext.resolution_md + + doc_item, doc = await create_draft_from_source( + session, + ws_id=ws_id, + source=source, + author_id=ctx.user.id, + generated_by="rule" if source.kind == "ticket" else "human", + resolution_md=resolution, + doc_type=doc_type, + ) + await _log( + session, doc_item, ctx.user.id, "knowledge_created", {"source_item_id": str(source.id)} + ) + await audit_record( + session, + workspace_id=ws_id, + actor_user_id=ctx.user.id, + action="knowledge.promote", + target_type="item", + target_id=str(doc_item.id), + payload={"source_item_id": str(source.id)}, + ) + # 与"新建知识"返回同一种形状:知识本体 + source,前端不用记两套 + return ok(await _knowledge_out(session, ws_id, doc_item, doc)) diff --git a/backend/app/api/tickets.py b/backend/app/api/tickets.py index 4ed8d73..df6575e 100644 --- a/backend/app/api/tickets.py +++ b/backend/app/api/tickets.py @@ -288,6 +288,41 @@ async def promote(item_id: uuid.UUID, body: PromoteIn, ctx: MemberDep, session: # -- 办理/验收 ---------------------------------------------------------- +async def _draft_knowledge( + session: AsyncSession, + ctx: TenantCtx, + ticket: Item, + ext: TicketExt, + resolution_md: str, + now: datetime, +) -> None: + """验收通过时自动整理一份知识草稿(M3 闭环沉淀)。 + + 只落 draft 不发布:自动整理的东西得有人扫一眼才敢进团队检索。 + 同一张工单只沉淀一次(重复点验收、历史数据补跑都不会重复产出)。 + """ + from ..services.knowledge import create_draft_from_source, find_doc_by_source + + if await find_doc_by_source(session, ctx.ws_id, ticket.id) is not None: + return + doc_item, _doc = await create_draft_from_source( + session, + ws_id=ctx.ws_id, + source=ticket, + author_id=ctx.user.id, + generated_by="rule", + resolution_md=resolution_md or ext.resolution_md or "", + doc_type="postmortem", + ) + await _log( + session, + doc_item, + ctx.user.id, + "knowledge_created", + {"source_item_id": str(ticket.id), "generated_by": "rule"}, + ) + + async def _advance( ctx: TenantCtx, session: AsyncSession, @@ -391,6 +426,11 @@ async def _advance( body="它对应的工单已验收通过", ) + # 验收通过 -> 顺手整理一份知识草稿(闭环沉淀)。 + # 只落草稿不发布:自动整理的东西得有人扫一眼才敢进团队检索。 + if action == "approve": + await _draft_knowledge(session, ctx, ticket, ext, resolution_md, now) + await session.flush() return await _ticket_out(session, ctx.ws_id, ticket, ext) diff --git a/backend/app/domain/knowledge.py b/backend/app/domain/knowledge.py new file mode 100644 index 0000000..91484a9 --- /dev/null +++ b/backend/app/domain/knowledge.py @@ -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 "" diff --git a/backend/app/errors.py b/backend/app/errors.py index cfd7750..6930ab2 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -50,6 +50,13 @@ class ErrorCode(IntEnum): TICKET_FORBIDDEN = 6004 NOTIFICATION_NOT_FOUND = 6005 + # 7xxx 知识域 + KNOWLEDGE_NOT_FOUND = 7001 + KNOWLEDGE_ALREADY_EXISTS = 7002 + KNOWLEDGE_STATUS_INVALID = 7003 + KNOWLEDGE_FORBIDDEN = 7004 + KNOWLEDGE_SOURCE_REQUIRED = 7005 + # 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层 _HTTP_STATUS: dict[ErrorCode, int] = { @@ -83,6 +90,11 @@ _HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.TICKET_STATUS_INVALID: 409, ErrorCode.TICKET_FORBIDDEN: 403, ErrorCode.NOTIFICATION_NOT_FOUND: 404, + ErrorCode.KNOWLEDGE_NOT_FOUND: 404, + ErrorCode.KNOWLEDGE_ALREADY_EXISTS: 409, + ErrorCode.KNOWLEDGE_STATUS_INVALID: 409, + ErrorCode.KNOWLEDGE_FORBIDDEN: 403, + ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409, } diff --git a/backend/app/main.py b/backend/app/main.py index aad1782..a191643 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse from sqlalchemy import text from starlette.exceptions import HTTPException as StarletteHTTPException -from .api import auth, items, tickets, workspaces +from .api import auth, items, knowledge, tickets, workspaces from .config import get_settings from .db import SessionFactory, current_app_role, engine from .errors import AppError, ErrorCode, http_status_for @@ -104,6 +104,7 @@ app.include_router(items.router, prefix=settings.api_prefix) app.include_router(items.tag_router, prefix=settings.api_prefix) app.include_router(tickets.router, prefix=settings.api_prefix) app.include_router(tickets.notify_router, prefix=settings.api_prefix) +app.include_router(knowledge.router, prefix=settings.api_prefix) @app.get("/health", tags=["运维"], summary="存活探针") diff --git a/backend/app/models.py b/backend/app/models.py index 81949e9..598c9ec 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -135,6 +135,7 @@ class AuditLog(Base): # # 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base, # 所以只能在文件末尾反向 import,否则循环导入。 +from .models_knowledge import KNOWLEDGE_TENANT_TABLES # noqa: E402 from .models_task import TASK_TENANT_TABLES # noqa: E402 from .models_ticket import TICKET_TENANT_TABLES # noqa: E402 @@ -145,4 +146,5 @@ TENANT_TABLES: tuple[str, ...] = ( "audit_log", *TASK_TENANT_TABLES, *TICKET_TENANT_TABLES, + *KNOWLEDGE_TENANT_TABLES, ) diff --git a/backend/app/models_knowledge.py b/backend/app/models_knowledge.py new file mode 100644 index 0000000..3e11572 --- /dev/null +++ b/backend/app/models_knowledge.py @@ -0,0 +1,79 @@ +"""知识域模型(M3):knowledge_doc。 + +三条设计约束(对应 PRODUCT-PLAN 的 D1/D3): + +1. **知识也是 `item`**。标题、正文、标签、评论、事件流全部复用;`knowledge_doc` + 只补"从哪来的、是什么类型、什么时候被人认可过"。同一套列表、搜索、详情、权限对知识依然成立。 +2. **知识必须有来源**(D3)。`source_kind` 为 `task` / `ticket` 时必须给出 `source_item_id`, + 只有 `manual`(自己记的笔记)允许留空。这条写在数据库 CHECK 里,不靠 API 自律—— + 将来接 LLM 自动归档时,没有来源的知识等于"没人能核对的断言",比没有更糟。 +3. **状态机是 `draft → in_review → published → archived`**。草稿可以随时改,发布的要有 + 人来点头(`published_at` 记时间),归档的不删只藏(历史结论不该消失,只该退出检索)。 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + Index, + String, + Text, +) +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column + +from .models import Base, Timestamps + +# 知识状态机(与任务/工单共用 item.status 一列,取值集合不同) +KNOWLEDGE_STATUSES: tuple[str, ...] = ("draft", "in_review", "published", "archived") + +DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision") +SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket") +GENERATED_BY: tuple[str, ...] = ("human", "rule") + + +class KnowledgeDoc(Base, Timestamps): + """知识的附加信息,与 item 一比一。""" + + __tablename__ = "knowledge_doc" + __table_args__ = ( + Index("ix_knowledge_ws_status", "workspace_id", "status_hint"), + Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"), + CheckConstraint( + "source_kind = 'manual' OR source_item_id IS NOT NULL", + name="ck_knowledge_source_required", + ), + CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"), + ) + + # item_id 既是主键又是外键:一条 item 最多一条 knowledge_doc,天然 1:1 + item_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False + ) + author_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False + ) + # 知识的来源。NULL 只允许出现在 source_kind='manual' 上(见 CHECK) + source_item_id: Mapped[uuid.UUID | None] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL") + ) + source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") + doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note") + # 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表 + status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft") + summary: Mapped[str] = mapped_column(Text, nullable=False, default="") + generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human") + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + chunk_count: Mapped[int] = mapped_column(nullable=False, default=0) + + +KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc",) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/knowledge.py b/backend/app/services/knowledge.py new file mode 100644 index 0000000..c7c3830 --- /dev/null +++ b/backend/app/services/knowledge.py @@ -0,0 +1,136 @@ +"""知识域的数据库配套动作(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) + .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} for a, at 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 "" diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py new file mode 100644 index 0000000..230f157 --- /dev/null +++ b/backend/tests/test_knowledge.py @@ -0,0 +1,342 @@ +"""M3 知识域测试:草稿、来源、审议发布、隔离。 + +重点不是"接口能通",而是这几条容易做错的: + +1. **知识的来源不可丢**:从工单沉淀出来的知识必须指回那张工单(`source_kind='ticket'`), + 否则半年后没人能核对这条结论从哪来; +2. **自动沉淀只落草稿**:验收通过后知识是 `draft`,不是 `published`; +3. **同一件事只沉淀一次**:重复点"沉淀成知识"要报 7002,而不是悄悄生成两份; +4. **发布要管理员**:普通成员的发布请求必须是 403(7004); +5. **跨租户不可见**:别人的知识列表为空、直接按 id 取是 7001。 +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from httpx import AsyncClient + +from .conftest import create_workspace, login, unwrap + +V1 = "/api/v1" + + +async def _member( + client: AsyncClient, owner: dict[str, Any], ws: dict[str, Any], role: str = "member" +) -> dict[str, Any]: + user = await login(client) + inv = unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/invitations", json={"role": role}, headers=owner["headers"] + ) + ) + unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/join", json={"code": inv["code"]}, headers=user["headers"] + ) + ) + return user + + +async def _task(client: AsyncClient, user: dict[str, Any], ws_id: str, title: str) -> dict: + return unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/items", + json={"title": title, "status": "todo"}, + headers=user["headers"], + ) + ) + + +async def _finish_ticket( + client: AsyncClient, owner: dict[str, Any], ws_id: str, helper: dict[str, Any], task: dict +) -> dict: + """把任务升级成工单并走完到验收通过,返回工单。""" + ticket = unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/items/{task['id']}/promote", + json={"assignee_id": helper["user_id"]}, + headers=owner["headers"], + ) + )["ticket"] + await client.post( + f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/accept", headers=helper["headers"] + ) + await client.post( + f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/start", headers=helper["headers"] + ) + unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/submit", + json={"note": "已经开了只读账号"}, + headers=helper["headers"], + ) + ) + unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/verify", + json={"approve": True, "resolution_md": "账号权限已开通,走的是 A 方案"}, + headers=owner["headers"], + ) + ) + return ticket + + +@pytest.mark.asyncio +async def test_create_manual_knowledge_is_draft(client: AsyncClient) -> None: + owner = await login(client) + ws = await create_workspace(client, owner, "知识手记") + + data = unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge", + json={ + "title": "报销单要贴发票原件", + "body_md": "财务只认纸质发票,扫描件不行。", + "doc_type": "note", + "tag_names": ["财务"], + }, + headers=owner["headers"], + ) + ) + assert data["kind"] == "knowledge" + assert data["status"] == "draft" + assert data["knowledge"]["source_kind"] == "manual" + assert data["knowledge"]["source_item_id"] is None + assert [t["name"] for t in data["tags"]] == ["财务"] + + listed = unwrap( + await client.get(f"{V1}/workspaces/{ws['id']}/knowledge", headers=owner["headers"]) + ) + assert [i["id"] for i in listed["items"]] == [data["id"]] + + +@pytest.mark.asyncio +async def test_ticket_approval_creates_draft_with_source(client: AsyncClient) -> None: + owner = await login(client) + ws = await create_workspace(client, owner, "闭环沉淀") + helper = await _member(client, owner, ws) + task = await _task(client, owner, ws["id"], "开通测试账号权限") + ticket = await _finish_ticket(client, owner, ws["id"], helper, task) + + listed = unwrap( + await client.get( + f"{V1}/workspaces/{ws['id']}/knowledge", + params={"status": "draft"}, + headers=owner["headers"], + ) + ) + assert listed["has_more"] is False + docs = [i for i in listed["items"] if i["knowledge"]["source_item_id"] == ticket["id"]] + assert len(docs) == 1, "验收通过应当恰好沉淀一份草稿" + doc = docs[0] + assert doc["status"] == "draft", "自动沉淀只能是草稿,不能直接发布" + assert doc["knowledge"]["source_kind"] == "ticket" + assert doc["knowledge"]["generated_by"] == "rule" + # 草稿正文必须带结论和"请先核对"的提示,否则用户会把它当已确认结论转发 + assert "账号权限已开通" in doc["body_md"] + assert "请先核对再发布" in doc["body_md"] + assert doc["source"]["kind"] == "ticket" + + +@pytest.mark.asyncio +async def test_promote_twice_conflicts(client: AsyncClient) -> None: + owner = await login(client) + ws = await create_workspace(client, owner, "重复沉淀") + task = await _task(client, owner, ws["id"], "写周报") + + first = unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/items/{task['id']}/knowledge", + headers=owner["headers"], + ) + ) + assert first["knowledge"]["source_kind"] == "task" + assert first["knowledge"]["generated_by"] == "human" + + resp = await client.post( + f"{V1}/workspaces/{ws['id']}/items/{task['id']}/knowledge", headers=owner["headers"] + ) + assert resp.json()["code"] == 7002 + + +@pytest.mark.asyncio +async def test_publish_requires_admin(client: AsyncClient) -> None: + owner = await login(client) + ws = await create_workspace(client, owner, "发布权限") + member = await _member(client, owner, ws) + doc = unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge", + json={"title": "值班手册", "body_md": "先重启再上报。"}, + headers=member["headers"], + ) + ) + + reviewed = unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status", + json={"action": "submit_review", "version": doc["version"]}, + headers=member["headers"], + ) + ) + assert reviewed["status"] == "in_review" + + denied = await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status", + json={"action": "publish", "version": reviewed["version"]}, + headers=member["headers"], + ) + assert denied.json()["code"] == 7004 + + published = unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status", + json={"action": "publish", "version": reviewed["version"]}, + headers=owner["headers"], + ) + ) + assert published["status"] == "published" + assert published["knowledge"]["published_at"] is not None + + # 已发布的内容不许直接改,先撤回 + blocked = await client.patch( + f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}", + json={"title": "值班手册 v2", "version": published["version"]}, + headers=owner["headers"], + ) + assert blocked.json()["code"] == 7003 + + archived = unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status", + json={"action": "archive", "version": published["version"]}, + headers=owner["headers"], + ) + ) + assert archived["status"] == "archived" + assert archived["knowledge"]["archived_at"] is not None + + +@pytest.mark.asyncio +async def test_illegal_action_reports_allowed(client: AsyncClient) -> None: + owner = await login(client) + ws = await create_workspace(client, owner, "非法动作") + doc = unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge", + json={"title": "草稿"}, + headers=owner["headers"], + ) + ) + unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status", + json={"action": "archive", "version": doc["version"]}, + headers=owner["headers"], + ) + ) + resp = await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status", + json={"action": "publish", "version": doc["version"]}, + headers=owner["headers"], + ) + body = resp.json() + assert body["code"] == 7003 + assert body["data"]["allowed_from"] == ["draft", "in_review"] + assert "restore" in body["data"]["allowed_actions"] + + +@pytest.mark.asyncio +async def test_knowledge_is_tenant_scoped(client: AsyncClient) -> None: + alice = await login(client) + ws_a = await create_workspace(client, alice, "A 的知识") + doc = unwrap( + await client.post( + f"{V1}/workspaces/{ws_a['id']}/knowledge", + json={"title": "A 的内部结论"}, + headers=alice["headers"], + ) + ) + + bob = await login(client) + ws_b = await create_workspace(client, bob, "B 的工作区") + + listed = unwrap( + await client.get(f"{V1}/workspaces/{ws_b['id']}/knowledge", headers=bob["headers"]) + ) + assert listed["items"] == [] + + cross = await client.get( + f"{V1}/workspaces/{ws_b['id']}/knowledge/{doc['id']}", headers=bob["headers"] + ) + assert cross.json()["code"] == 7001 + + not_member = await client.get(f"{V1}/workspaces/{ws_a['id']}/knowledge", headers=bob["headers"]) + assert not_member.json()["code"] == 3003 + + +@pytest.mark.asyncio +async def test_search_and_filters(client: AsyncClient) -> None: + owner = await login(client) + ws = await create_workspace(client, owner, "检索") + for title, doc_type in ( + ("周报模板", "howto"), + ("客户投诉复盘", "postmortem"), + ("周报提醒", "note"), + ): + unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge", + json={"title": title, "body_md": f"{title} 的正文", "doc_type": doc_type}, + headers=owner["headers"], + ) + ) + + hit = unwrap( + await client.get( + f"{V1}/workspaces/{ws['id']}/knowledge", + params={"q": "周报"}, + headers=owner["headers"], + ) + ) + assert sorted(i["title"] for i in hit["items"]) == ["周报提醒", "周报模板"] + + by_type = unwrap( + await client.get( + f"{V1}/workspaces/{ws['id']}/knowledge", + params={"doc_type": "postmortem"}, + headers=owner["headers"], + ) + ) + assert [i["title"] for i in by_type["items"]] == ["客户投诉复盘"] + + +@pytest.mark.asyncio +async def test_patch_uses_optimistic_lock(client: AsyncClient) -> None: + owner = await login(client) + ws = await create_workspace(client, owner, "并发改") + doc = unwrap( + await client.post( + f"{V1}/workspaces/{ws['id']}/knowledge", + json={"title": "初稿", "version": 0}, + headers=owner["headers"], + ) + ) + updated = unwrap( + await client.patch( + f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}", + json={"body_md": "补一段", "version": doc["version"]}, + headers=owner["headers"], + ) + ) + assert updated["version"] == doc["version"] + 1 + + stale = await client.patch( + f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}", + json={"body_md": "又一段", "version": doc["version"]}, + headers=owner["headers"], + ) + assert stale.json()["code"] == 4009 + assert stale.json()["data"]["current_version"] == updated["version"] diff --git a/web/e2e/knowledge.mjs b/web/e2e/knowledge.mjs new file mode 100644 index 0000000..521b466 --- /dev/null +++ b/web/e2e/knowledge.mjs @@ -0,0 +1,135 @@ +/** + * 知识闭环 e2e:一件事 → 知识草稿 → 核对 → 发布。 + * + * 走的是真实浏览器 + 真实后端(8100)。前面造数据用接口, + * 后面验证界面,这样脚本短、失败点也清楚。 + */ +import { chromium } from "playwright-core"; + +const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; +const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; +const stamp = String(Date.now()).slice(-7); +const phone = `135${stamp}0`; + +let failures = 0; +function check(name, ok, extra = "") { + console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`); + if (!ok) failures += 1; +} + +async function call(method, path, body, token) { + const res = await fetch(`${API}${path}`, { + method, + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(method === "GET" ? {} : { "idempotency-key": `e2e-${Math.random()}` }), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + const json = text ? JSON.parse(text) : {}; + if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`); + return json.data; +} + +const browser = await chromium.launch({ + executablePath: + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`, +}); +const consoleErrors = []; +const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); +const page = await context.newPage(); +page.on("console", (m) => { + if (m.type() === "error") consoleErrors.push(m.text()); +}); +page.on("response", (r) => { + if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`); +}); + +try { + await call("POST", "/auth/sms/send", { phone }); + const login = await call("POST", "/auth/login", { phone, code: "123456" }); + const token = login.tokens.access_token; + const ws = await call("POST", "/workspaces", { name: `知识闭环 ${stamp}` }, token); + const task = await call( + "POST", + `/workspaces/${ws.id}/items`, + { title: "发票原件必须一起寄", body_md: "电子发票打印件不算,财务退回过一次。" }, + token, + ); + const moved = await call( + "POST", + `/workspaces/${ws.id}/items/${task.id}/status`, + { status: "todo", version: task.version }, + token, + ); + await call( + "POST", + `/workspaces/${ws.id}/items/${task.id}/status`, + { status: "done", version: moved.version }, + token, + ); + + await page.addInitScript( + ([access, refresh]) => { + localStorage.setItem("dadao.access", access); + localStorage.setItem("dadao.refresh", refresh); + }, + [token, login.tokens.refresh_token], + ); + await page.goto(`${WEB}/w/${ws.id}`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "已完成", exact: true }).click(); + await page.getByText("发票原件必须一起寄", { exact: false }).first().click(); + await page.getByRole("button", { name: "沉淀成知识", exact: true }).click(); + await page.getByText("已生成知识草稿", { exact: false }).waitFor({ timeout: 10_000 }); + check("任务详情能把这件事沉淀成草稿", true); + + await page.getByRole("button", { name: "关闭", exact: true }).click(); + + await page.getByRole("link", { name: "知识", exact: true }).click(); + const card = page.getByText("发票原件必须一起寄", { exact: false }).first(); + await card.waitFor({ timeout: 10_000 }); + const draftLabelVisible = await page.getByText("草稿", { exact: true }).first().isVisible(); + check("草稿出现在「待核对」", draftLabelVisible); + await page.screenshot({ path: "e2e/artifacts/knowledge-draft.png", fullPage: false }); + + await page.getByRole("button", { name: "打开", exact: true }).first().click(); + // 等这一个请求落地再断言,不然会和抽屉的刷新抢跑("已发布"这四个字在 tab 上也有一份) + const [publishResp] = await Promise.all([ + page.waitForResponse( + (r) => r.url().includes("/knowledge/") && r.url().endsWith("/status") && r.status() === 200, + ), + page.getByRole("button", { name: "发布", exact: true }).click(), + ]); + check("发布请求成功", publishResp.ok()); + await page.getByText(/已发布 · 版本 \d+/, { exact: false }).waitFor({ timeout: 10_000 }); + const published = await page.getByText(/已发布 · 版本 \d+/, { exact: false }).first().isVisible(); + check("核对后可以发布", published); + + const again = await fetch(`${API}/workspaces/${ws.id}/knowledge`, { + headers: { authorization: `Bearer ${token}` }, + }).then((r) => r.json()); + // 同标题可能有多条(每次跑脚本都会新建工作区,但标题固定在两处),取已发布的那条 + const doc = again.data.items.find( + (d) => d.title === "发票原件必须一起寄" && d.knowledge.status_hint === "published", + ); + check( + "发布后接口里 published 且有发布时间", + doc?.knowledge.status_hint === "published" && Boolean(doc?.knowledge.published_at), + ); + check("正文带「请先核对再发布」的提示", String(doc?.body_md ?? "").includes("请先核对再发布")); + await page.screenshot({ path: "e2e/artifacts/knowledge-published.png", fullPage: false }); + + check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); +} catch (err) { + failures += 1; + console.log(`FAIL 脚本异常 — ${err.message}`); + await page.screenshot({ path: "e2e/artifacts/knowledge-failure.png" }).catch(() => {}); + console.log((await page.textContent("body"))?.slice(0, 600)); +} finally { + await browser.close(); + console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`); + process.exit(failures === 0 ? 0 : 1); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 3521977..60b5dd6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,6 +8,7 @@ import { WorkspacePage } from "./pages/Workspace"; import { TasksPage } from "./pages/Tasks"; import { BoardPage } from "./pages/Board"; import { TicketsPage } from "./pages/Tickets"; +import { KnowledgePage } from "./pages/Knowledge"; import { JoinPage } from "./pages/Join"; interface AuthState { @@ -86,6 +87,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/TaskDetail.tsx b/web/src/components/TaskDetail.tsx index 519ce70..b477518 100644 --- a/web/src/components/TaskDetail.tsx +++ b/web/src/components/TaskDetail.tsx @@ -201,6 +201,25 @@ export function TaskDetail({ } } + /** 把这件事整理成知识草稿。草稿默认待核对,不直接发布。 */ + async function toKnowledge() { + if (!item) return; + setBusy(true); + setError(null); + try { + await api.post(`${base}/knowledge?doc_type=${item.kind === "ticket" ? "postmortem" : "howto"}`); + setNotice("已生成知识草稿。它现在在「知识 → 待核对」里,核对无误后再发布,才会进入团队检索。"); + } catch (err) { + if (err instanceof ApiError && err.code === 7002) { + setNotice("这件事已经有一份知识草稿了,去「知识」页找它。"); + } else { + setError(err instanceof ApiError ? err.message : "生成失败"); + } + } finally { + setBusy(false); + } + } + async function ticketAction(action: TicketAction, extra: Record = {}) { if (!item) return; setBusy(true); @@ -412,6 +431,20 @@ export function TaskDetail({ )} + {item.kind !== "knowledge" && item.status !== "canceled" && ( +
+

这件事值得留下来?沉淀成知识

+

+ 会把标题、备注、时间线和评论整理成一份草稿,默认进「待核对」,不会直接对外发布。 +

+
+ +
+
+ )} + {notice &&

{notice}

} {error &&

{error}

} {busy &&

处理中…

} diff --git a/web/src/components/WorkspaceNav.tsx b/web/src/components/WorkspaceNav.tsx index 9467a02..5cefa7a 100644 --- a/web/src/components/WorkspaceNav.tsx +++ b/web/src/components/WorkspaceNav.tsx @@ -6,6 +6,7 @@ const TABS = [ { path: "", label: "任务" }, { path: "/board", label: "看板" }, { path: "/tickets", label: "工单" }, + { path: "/knowledge", label: "知识" }, { path: "/members", label: "成员" }, ]; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 43f4ba0..b8a396e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -112,3 +112,31 @@ export interface Notification { read_at: string | null; created_at: string; } + +export type KnowledgeStatus = "draft" | "in_review" | "published" | "archived"; + +export interface KnowledgeInfo { + doc_type: string; + summary: string; + status_hint: KnowledgeStatus; + source_kind: "manual" | "task" | "ticket"; + source_item_id: string | null; + generated_by: "human" | "rule"; + published_at: string | null; + archived_at: string | null; + chunk_count: number; + author_id: string; +} + +export interface KnowledgeSource { + id: string; + kind: string; + title: string; + status: string; +} + +export interface Knowledge extends Item { + knowledge: KnowledgeInfo; + source: KnowledgeSource | null; + activities?: Activity[]; +} diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx new file mode 100644 index 0000000..a367b8f --- /dev/null +++ b/web/src/pages/Knowledge.tsx @@ -0,0 +1,390 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; +import { Layout } from "../components/Layout"; +import { WorkspaceNav } from "../components/WorkspaceNav"; +import { ApiError, api } from "../lib/client"; +import type { Knowledge, KnowledgeStatus } from "../lib/types"; + +const TABS = [ + { key: "draft", label: "待核对", statuses: ["draft", "in_review"] }, + { key: "published", label: "已发布", statuses: ["published"] }, + { key: "archived", label: "已归档", statuses: ["archived"] }, + { key: "all", label: "全部", statuses: [] }, +] as const; + +const DOC_TYPE_LABEL: Record = { + note: "笔记", + howto: "怎么做", + faq: "常见问题", + postmortem: "复盘", + decision: "决策", +}; + +const STATUS_LABEL: Record = { + draft: "草稿", + in_review: "待审议", + published: "已发布", + archived: "已归档", +}; + +const SOURCE_LABEL: Record = { + manual: "手记", + task: "来自任务", + ticket: "来自工单", +}; + +const ACTION_LABEL: Record = { + submit_review: "提交审议", + withdraw: "撤回审议", + publish: "发布", + archive: "归档", + restore: "恢复成草稿", +}; + +export function KnowledgePage() { + const { wsId = "" } = useParams(); + const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("draft"); + const [q, setQ] = useState(""); + const [docs, setDocs] = useState([]); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [openId, setOpenId] = useState(null); + const [adding, setAdding] = useState(false); + const [draftTitle, setDraftTitle] = useState(""); + const reqId = useRef(0); + + const load = useCallback( + async (nextCursor: string | null) => { + // 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果 + const mine = ++reqId.current; + setLoading(true); + setError(null); + try { + const qs = new URLSearchParams(); + const statuses = TABS.find((t) => t.key === tab)!.statuses; + for (const s of statuses) qs.append("status", s); + if (q.trim()) qs.set("q", q.trim()); + if (nextCursor) qs.set("cursor", nextCursor); + const page = await api.get<{ + items: Knowledge[]; + next_cursor: string | null; + has_more: boolean; + }>(`/workspaces/${wsId}/knowledge?${qs.toString()}`); + if (mine !== reqId.current) return; + setDocs((prev) => (nextCursor ? [...prev, ...page.items] : page.items)); + setCursor(page.has_more ? page.next_cursor : null); + } catch (err) { + if (mine !== reqId.current) return; + setError(err instanceof ApiError ? err.message : "知识加载失败"); + } finally { + if (mine === reqId.current) setLoading(false); + } + }, + [wsId, tab, q], + ); + + useEffect(() => { + void load(null); + }, [load]); + + async function addNote() { + const title = draftTitle.trim(); + if (!title) return; + try { + const doc = await api.post(`/workspaces/${wsId}/knowledge`, { + title, + doc_type: "note", + }); + setDraftTitle(""); + setAdding(false); + setTab("draft"); + setOpenId(doc.id); + void load(null); + } catch (err) { + setError(err instanceof ApiError ? err.message : "保存失败"); + } + } + + return ( + +
+
+

知识

+

+ 工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索 +

+
+ +
+ +
+ {TABS.map((t) => ( + + ))} + setQ(e.target.value)} + /> + +
+ + {adding && ( +
+

+ 手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。 +

+
+ setDraftTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void addNote(); + }} + /> + +
+
+ )} + + {error &&

{error}

} + + {docs.length === 0 && !loading && ( +
+ {tab === "draft" + ? "还没有待核对的知识。把工单验收通过,或者在这里「记一条」。" + : "这里还空着。"} +
+ )} + +
    + {docs.map((d) => ( +
  • +
    + + +
    +
  • + ))} +
+ + {cursor && ( +
+ +
+ )} + + {openId && ( + setOpenId(null)} + onChanged={() => void load(null)} + /> + )} +
+ ); +} + +/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */ +function KnowledgeDrawer({ + wsId, + itemId, + onClose, + onChanged, +}: { + wsId: string; + itemId: string; + onClose: () => void; + onChanged: () => void; +}) { + const [doc, setDoc] = useState(null); + const [error, setError] = useState(null); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + const data = await api.get(`/workspaces/${wsId}/knowledge/${itemId}`); + setDoc(data); + setTitle(data.title); + setBody(data.body_md); + } catch (err) { + setError(err instanceof ApiError ? err.message : "加载失败"); + } + }, [wsId, itemId]); + + useEffect(() => { + void load(); + }, [load]); + + async function save() { + if (!doc) return; + setBusy(true); + setError(null); + try { + const next = await api.patch(`/workspaces/${wsId}/knowledge/${itemId}`, { + title, + body_md: body, + version: doc.version, + }); + setDoc(next); + onChanged(); + } catch (err) { + if (err instanceof ApiError && err.code === 4009) { + setError("刚被别人改过,已刷新为最新内容"); + await load(); + } else { + setError(err instanceof ApiError ? err.message : "保存失败"); + } + } finally { + setBusy(false); + } + } + + async function act(action: string) { + if (!doc) return; + setBusy(true); + setError(null); + try { + const next = await api.post(`/workspaces/${wsId}/knowledge/${itemId}/status`, { + action, + version: doc.version, + }); + setDoc(next); + onChanged(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "操作失败"); + await load(); + } finally { + setBusy(false); + } + } + + const status = doc?.knowledge.status_hint; + const canEdit = status === "draft" || status === "in_review"; + const actions: { key: string; label: string; show: boolean }[] = [ + { key: "submit_review", label: ACTION_LABEL.submit_review, show: status === "draft" }, + { key: "withdraw", label: ACTION_LABEL.withdraw, show: status === "in_review" }, + { + key: "publish", + label: ACTION_LABEL.publish, + show: status === "draft" || status === "in_review", + }, + { + key: "archive", + label: ACTION_LABEL.archive, + show: status === "draft" || status === "in_review" || status === "published", + }, + { key: "restore", label: ACTION_LABEL.restore, show: status === "archived" }, + ]; + + return ( +
+