feat: online verification, node removals, and knowledge browser
CI / quality (push) Canceled after 0s
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:
1 parent
a3156f4e08
commit
78033bb830
83 files changed
+13476
-1051
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user