"""HTTP surface of the knowledge-loop review queue. GET /api/v1/knowledge/proposals GET /api/v1/knowledge/proposals/summary GET /api/v1/knowledge/proposals/{proposal_id} POST /api/v1/knowledge/proposals/{proposal_id}/decide POST /api/v1/knowledge/proposals/bulk-decide POST /api/v1/knowledge/lint POST /api/v1/knowledge/backfill/answers POST /api/v1/knowledge/backfill/eval-run/{run_id} GET /api/v1/knowledge/kb/index GET /api/v1/knowledge/kb/articles/{article_id}/links DELETE /api/v1/knowledge/kb/links/{link_id} Corpus-agnostic on purpose: the queue is one place, and a second corpus joins by registering an adapter rather than by growing a second review page. **Reads are ``global_admin`` + ``auditor``, writes are ``global_admin``.** A proposal carries a suggested edit to published guidance; accepting one changes what every employee is told. The auditor reading it is governance, and the split mirrors the knowledge-base admin surface it feeds. """ from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field, field_validator from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( check_sensitive_endpoint_rate_limit, require_admin, require_roles, ) from app.core.exceptions import ConflictError, NotFoundError, ValidationError from app.core.knowledge_loop import service as loop from app.core.knowledge_loop.constants import ( CORPORA, KINDS, KINDS_BULK_SAFE, SOURCES, SOURCES_NEEDING_A_READ, STATUS_OPEN, STATUSES, ) from app.core.knowledge_loop.models import KnowledgeProposal from app.core.knowledge_loop.registry import adapter_for, registered_corpora from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE from app.core.response import paginated_response, success_response router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge-loop"]) _read_gate = require_roles(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE) def _trace_id(request: Request) -> str: return getattr(request.state, "trace_id", None) or "knowledge-loop" async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_id: str | None, detail: dict[str, Any] | None = None, ) -> None: await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type="knowledge_proposal", resource_id=resource_id, detail=detail, ) async def _get(db: AsyncSession, proposal_id: uuid.UUID) -> KnowledgeProposal: row = await db.get(KnowledgeProposal, proposal_id) if row is None: raise NotFoundError("这条提案不存在。") return row # ── queue ─────────────────────────────────────────────────────────── # NOTE: must stay registered BEFORE ``GET /proposals/{proposal_id}`` — FastAPI # matches in registration order and the path-param route would otherwise # swallow ``summary`` as a (non-UUID) proposal id. @router.get("/proposals/summary", summary="Open counts per corpus and kind") async def summary( request: Request, _user: AuthUser = Depends(_read_gate), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: from app.domains.auditor.security_faq_wiki import library_health from app.domains.support.kb_wiki import index_health data = await loop.queue_summary(db) data["corpora"] = list(CORPORA) data["registered"] = registered_corpora() data["kinds"] = list(KINDS) data["sources"] = list(SOURCES) # Per-corpus health, because the two corpora are healthy in different # ways: the knowledge base's index needs summaries, the security library # needs the employee's own words. One shared set of numbers would have to # be so generic it said nothing about either. data["kb_index"] = await index_health(db) data["security_faq"] = await library_health(db) return success_response(data=data, request=request) @router.get("/proposals", summary="The review queue") async def list_proposals( request: Request, corpus: str | None = Query(default=None), status: str | None = Query(default=STATUS_OPEN), kind: str | None = Query(default=None), source: str | None = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), _user: AuthUser = Depends(_read_gate), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: rows, total = await loop.list_proposals( db, corpus=corpus if corpus in CORPORA else None, status=status if status in STATUSES else None, kind=kind if kind in KINDS else None, source=source if source in SOURCES else None, page=page, page_size=page_size, ) return paginated_response(rows, total, page, page_size, request) @router.get("/proposals/{proposal_id}", summary="One proposal, with its target") async def get_proposal( proposal_id: uuid.UUID, request: Request, _user: AuthUser = Depends(_read_gate), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """Includes the target page **as it stands today**. A proposal written last week against a page somebody has since rewritten is a proposal about a document that no longer exists. Rendering the version the proposer saw would be the most confident possible way to get a wrong decision, so the page is re-read on every open. """ row = await _get(db, proposal_id) data = loop.serialize(row) adapter = adapter_for(row.corpus) data["context"] = ( await adapter.describe(db, proposal=row) if adapter is not None else {} ) return success_response(data=data, request=request) class DecideBody(BaseModel): accept: bool note: str | None = Field(default=None, max_length=2000) #: Values the reviewer filled in that the machine refused to invent — today #: only the body of a ``new_article`` proposal raised from an evaluation #: failure, which is filed empty on purpose. The overridable keys are #: whitelisted per kind (``OVERRIDABLE_PAYLOAD_KEYS``) and anything else is #: refused; see ``service.decide``. #: #: Bounded like its sibling ``note``: the merged payload is persisted into a #: JSON column BEFORE the adapter's own truncation, so an unbounded value is #: stored at full size whatever the article write later clips it to. payload: dict[str, Any] | None = Field(default=None) @field_validator("payload") @classmethod def _bounded(cls, value: dict[str, Any] | None) -> dict[str, Any] | None: if value is None: return value if len(value) > 8: raise ValueError("payload 最多 8 个字段。") for key, item in value.items(): if len(str(key)) > 64: raise ValueError("payload 的字段名过长。") if isinstance(item, str) and len(item) > 16000: raise ValueError(f"payload.{key} 超过 16000 字。") return value @router.post("/proposals/{proposal_id}/decide", summary="Accept or reject one") async def decide( proposal_id: uuid.UUID, body: DecideBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: row = await _get(db, proposal_id) if row.status != STATUS_OPEN: raise ConflictError("这条提案已经处理过了。") result = await loop.decide( db, proposal=row, accept=body.accept, actor_person_id=user.id, note=body.note, trace_id=_trace_id(request), payload_override=body.payload, ) await _audit( db, request, user, action="knowledge_loop.decided", resource_id=str(proposal_id), detail={ "accept": body.accept, "outcome": result.get("status"), "applied_ref": result.get("applied_ref"), # Which blanks the reviewer filled in — not the values, which are # already on the proposal row, but the fact that a human supplied # them. "Accepted" reads differently when the content was written by # the person accepting it. # # Read from the RESULT, not from the request: keys outside the # per-kind whitelist are refused before the apply runs, and an audit # row claiming a human authored a field that was never applied is # the wrong half of the record to get wrong. "authored_fields": result.get("authored_fields"), }, ) await db.commit() return success_response(data={**loop.serialize(row), **result}, request=request) class BulkDecideBody(BaseModel): proposal_ids: list[uuid.UUID] = Field(min_length=1, max_length=100) accept: bool note: str | None = Field(default=None, max_length=2000) @router.post("/proposals/bulk-decide", summary="Decide many at once") async def bulk_decide( body: BulkDecideBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """Per-proposal outcomes, never a single verdict for the batch. Bulk-accepting fifteen alias suggestions is the normal case and should be one click. But one of them failing must not report the batch as failed, and must not roll back the fourteen that worked — so each is decided on its own and the response says what happened to each. ⚠️ **A bulk ACCEPT is restricted to the kinds that cannot make a page wrong.** Rejecting in bulk stays unrestricted — declining to act is never the dangerous direction. The list is ``KINDS_BULK_SAFE`` and it is checked here rather than only in the console, because a rule that lives in the layer that cannot enforce it is one API call away from not applying. """ if body.accept: blocked = ( ( await db.execute( select(KnowledgeProposal).where( KnowledgeProposal.id.in_(body.proposal_ids), KnowledgeProposal.status == STATUS_OPEN, or_( KnowledgeProposal.kind.not_in(KINDS_BULK_SAFE), # The kind rule alone is not enough. It rests on # "adding a word to a page cannot make the page # wrong", which holds only while the word came from # the corpus itself — these sources carry text # derived from one employee's own conversation, and # aliases are served verbatim to everybody. KnowledgeProposal.source.in_(SOURCES_NEEDING_A_READ), ), ) ) ) .scalars() .all() ) if blocked: kinds = sorted({str(row.kind) for row in blocked}) sources = sorted( { str(row.source) for row in blocked if row.source in SOURCES_NEEDING_A_READ } ) detail = "、".join(kinds) if sources: detail += "(来源:" + "、".join(sources) + ")" raise ValidationError( "这些不能批量接受,请逐条看过再决定:" + detail, ) rows = ( ( await db.execute( select(KnowledgeProposal).where( KnowledgeProposal.id.in_(body.proposal_ids), KnowledgeProposal.status == STATUS_OPEN, ) ) ) .scalars() .all() ) outcomes: dict[str, int] = {} details: list[dict[str, Any]] = [] for row in rows: result = await loop.decide( db, proposal=row, accept=body.accept, actor_person_id=user.id, note=body.note, trace_id=_trace_id(request), ) status = str(result.get("status")) outcomes[status] = outcomes.get(status, 0) + 1 details.append( { "id": str(row.id), "status": status, "error": result.get("error"), } ) await _audit( db, request, user, action="knowledge_loop.bulk_decided", resource_id=None, detail={"accept": body.accept, "outcomes": outcomes}, ) await db.commit() return success_response( data={ "requested": len(body.proposal_ids), "decided": len(rows), "outcomes": outcomes, "details": details, }, request=request, ) # ── producers ─────────────────────────────────────────────────────── @router.post("/lint", summary="Run the corpus lint now") async def run_lint( request: Request, window_days: int = Query(default=14, ge=1, le=90), _rate: None = Depends(check_sensitive_endpoint_rate_limit), user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: from app.domains.auditor.security_faq_wiki import lint_security_vocabulary from app.domains.support.kb_lint import run_lint as lint result = await lint(db, window_days=window_days) # Both corpora on one button. Two buttons would make "did I run the one # that matters?" a question the reviewer has to hold, and the two passes # read entirely different inputs — there is nothing to choose between. result["security_faq"] = await lint_security_vocabulary(db) await _audit( db, request, user, action="knowledge_loop.lint_run", resource_id=None, detail=result, ) await db.commit() return success_response(data=result, request=request) @router.post("/backfill/answers", summary="Offer helpful assistant answers back") async def backfill_answers( request: Request, window_days: int = Query(default=14, ge=1, le=90), limit: int = Query(default=10, ge=1, le=50), _rate: None = Depends(check_sensitive_endpoint_rate_limit), user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: from app.domains.support.kb_lint import backfill_from_answers result = await backfill_from_answers(db, window_days=window_days, limit=limit) await _audit( db, request, user, action="knowledge_loop.backfill_answers", resource_id=None, detail=result, ) await db.commit() return success_response(data=result, request=request) @router.post( "/backfill/eval-run/{run_id}", summary="Turn an evaluation run's corpus failures into proposals", ) async def backfill_eval( run_id: uuid.UUID, request: Request, limit: int = Query(default=20, ge=1, le=100), # The same limiter its two sibling producers carry. This one was the odd # one out, and an unbounded producer is a queue anybody with the role can # flood — which is how a review queue becomes a queue nobody reviews. _rate: None = Depends(check_sensitive_endpoint_rate_limit), user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """Only the failures the harness blamed on the corpus. ``tool_field_or_data`` is a data defect, ``action`` is a workflow and ``out_of_scope`` is a product decision — writing a document for any of them would paper over the real problem with prose. """ from app.domains.support.kb_lint import backfill_from_eval_failures result = await backfill_from_eval_failures(db, run_id=run_id, limit=limit) await _audit( db, request, user, action="knowledge_loop.backfill_eval", resource_id=str(run_id), detail=result, ) await db.commit() return success_response(data=result, request=request) # ── the index and the graph ───────────────────────────────────────── @router.get("/kb/index", summary="The knowledge base as a list") async def kb_index( request: Request, _user: AuthUser = Depends(_read_gate), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: from app.domains.support.kb_wiki import index entries = await index(db) return success_response( data={"entries": entries, "total": len(entries)}, request=request ) @router.get( "/kb/articles/{article_id}/links", summary="What this page is connected to" ) async def article_links( article_id: uuid.UUID, request: Request, _user: AuthUser = Depends(_read_gate), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: from app.domains.support.kb_wiki import links_for return success_response( data=await links_for(db, article_id=article_id), request=request ) @router.delete("/kb/links/{link_id}", summary="Remove one link") async def delete_link( link_id: uuid.UUID, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: from app.domains.support.models import SupportKbArticleLink row = await db.get(SupportKbArticleLink, link_id) if row is None: raise NotFoundError("这条关联不存在。") await db.delete(row) await _audit( db, request, user, action="knowledge_loop.link_deleted", resource_id=str(link_id), detail={"kind": row.kind}, ) await db.commit() return success_response(data={"deleted": True}, request=request) __all__ = ["router"]