feat: online verification, node removals, and knowledge browser
CI / quality (push) Canceled after 0s

Adds claim-bound online evidence records and citation verification scripts,
node removal transport/adapter, retrieval benchmark and owner-review tooling,
GraphCanvas/SourceDetail knowledge browser UI, and the ingest handoff helper.
Bumps the agents/gewu pack to the online-verification branch.
This commit is contained in:
vasily committed 2026-10-16 20:02:29 +08:00
1 parent a3156f4e08
commit 78033bb830
83 files changed
+13476 -1051

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
"""Authenticated, project-scoped node removal and restore routes."""
from collections.abc import Callable
from typing import Annotated, Literal
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Request, Response
from pydantic import BaseModel, ConfigDict
from gewu.adapters.sqlite_node_removals import NodeRemoval, RemovalConflict, SQLiteNodeRemovalStore
from gewu.application.identity import Principal
from gewu.application.reading import CanonicalNodeReader, ReadError
from gewu.domain.contracts import RecordId
class NodeRemovalRequest(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
confirmed: Literal[True]
class NodeRemovalItem(BaseModel):
removal_id: str
project_id: str
node_id: str
title: str
requested_at: str
restore_until: str
@classmethod
def from_record(cls, record: NodeRemoval) -> "NodeRemovalItem":
return cls(
removal_id=record.removal_id,
project_id=record.project_id,
node_id=record.node_id,
title=record.title,
requested_at=record.requested_at,
restore_until=record.restore_until,
)
class NodeRemovalList(BaseModel):
can_manage: bool
removals: list[NodeRemovalItem]
def register_node_removal_routes(
app: FastAPI,
store: SQLiteNodeRemovalStore,
reader: CanonicalNodeReader,
authenticate: Callable[..., Principal],
require_same_origin: Callable[[Request], None],
) -> None:
def human(
request: Request,
response: Response,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> Principal:
response.headers["Cache-Control"] = "private, no-store"
write = request.method not in {"GET", "HEAD"}
if write:
require_same_origin(request)
principal = authenticate(authorization, session_cookie, csrf_token, require_csrf=write)
if "agent" in principal.roles:
raise HTTPException(403, "A human account is required")
return principal
def require_manager(principal: Principal, project_id: str) -> None:
if not principal.can_read_project(project_id):
raise HTTPException(404, "Project removal queue is unavailable")
if not ({"editor", "admin", "owner"} & principal.roles):
raise HTTPException(403, "Editor role is required to remove or restore knowledge")
@app.get(
"/v1/projects/{project_id}/node-removals",
response_model=NodeRemovalList,
tags=["knowledge"],
operation_id="node_removal_list",
)
def list_removals(
project_id: RecordId,
principal: Annotated[Principal, Depends(human)],
) -> NodeRemovalList:
if not principal.can_read_project(project_id):
raise HTTPException(404, "Project removal queue is unavailable")
can_manage = bool({"editor", "admin", "owner"} & principal.roles)
removals = store.list_restorable(project_id) if can_manage else ()
return NodeRemovalList(
can_manage=can_manage,
removals=[NodeRemovalItem.from_record(record) for record in removals],
)
@app.post(
"/v1/projects/{project_id}/nodes/{node_id}/removal",
response_model=NodeRemovalItem,
status_code=201,
tags=["knowledge"],
operation_id="node_removal_request",
)
def request_removal(
project_id: RecordId,
node_id: RecordId,
body: NodeRemovalRequest,
principal: Annotated[Principal, Depends(human)],
) -> NodeRemovalItem:
require_manager(principal, project_id)
try:
envelope = reader.get_node(project_id, node_id, principal=principal)
title = str(envelope.data[0]["title"])
return NodeRemovalItem.from_record(
store.request(project_id, node_id, title, principal.principal_id)
)
except ReadError as error:
raise HTTPException(status_code=error.status_code, detail=str(error)) from error
except RemovalConflict as error:
raise HTTPException(status_code=409, detail=str(error)) from error
except RuntimeError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
@app.post(
"/v1/projects/{project_id}/nodes/{node_id}/restore-removal",
response_model=NodeRemovalItem,
tags=["knowledge"],
operation_id="node_removal_restore",
)
def restore_removal(
project_id: RecordId,
node_id: RecordId,
principal: Annotated[Principal, Depends(human)],
) -> NodeRemovalItem:
require_manager(principal, project_id)
try:
return NodeRemovalItem.from_record(
store.restore(project_id, node_id, principal.principal_id)
)
except KeyError as error:
raise HTTPException(status_code=404, detail="Removed node was not found") from error
except RemovalConflict as error:
raise HTTPException(status_code=410, detail=str(error)) from error
except RuntimeError as error:
raise HTTPException(status_code=503, detail=str(error)) from error