diff --git a/api/models/capability_request.py b/api/models/capability_request.py index 7051390..d30d8f7 100644 --- a/api/models/capability_request.py +++ b/api/models/capability_request.py @@ -66,6 +66,13 @@ class CapabilityRequest(Base, TimestampMixin): resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True) routing_note: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Dispute fields (populated when status = routing_disputed) + dispute_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + disputed_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + dispute_suggested_domain: Mapped[str | None] = mapped_column(String(100), nullable=True) + disputed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + accepted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/api/routers/capability_requests.py b/api/routers/capability_requests.py index b46f313..810cab4 100644 --- a/api/routers/capability_requests.py +++ b/api/routers/capability_requests.py @@ -17,8 +17,10 @@ from api.schemas.capability_request import ( CatalogRead, CapabilityRequestAccept, CapabilityRequestCreate, + CapabilityRequestDispute, CapabilityRequestPatch, CapabilityRequestRead, + CapabilityRequestReroute, CapabilityRequestStatusPatch, ) @@ -29,13 +31,14 @@ router = APIRouter(tags=["capability-requests"]) # --------------------------------------------------------------------------- _VALID_TRANSITIONS: dict[str, set[str]] = { - "requested": {"accepted", "rejected", "withdrawn"}, - "accepted": {"in_progress", "rejected", "withdrawn"}, - "in_progress": {"ready_for_review", "rejected", "withdrawn"}, - "ready_for_review": {"completed", "in_progress", "withdrawn"}, - "completed": set(), - "rejected": set(), - "withdrawn": set(), + "requested": {"accepted", "rejected", "withdrawn", "routing_disputed"}, + "routing_disputed": {"requested", "withdrawn"}, + "accepted": {"in_progress", "rejected", "withdrawn"}, + "in_progress": {"ready_for_review", "rejected", "withdrawn"}, + "ready_for_review": {"completed", "in_progress", "withdrawn"}, + "completed": set(), + "rejected": set(), + "withdrawn": set(), } @@ -331,6 +334,138 @@ async def patch_request( return req +# --------------------------------------------------------------------------- +# Dispute endpoints +# --------------------------------------------------------------------------- + +@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead) +async def dispute_request( + request_id: uuid.UUID, + body: CapabilityRequestDispute, + session: AsyncSession = Depends(get_session), +) -> CapabilityRequest: + """Flag a routing decision as incorrect. Transitions to routing_disputed.""" + req = await _get_request_or_404(request_id, session) + _check_transition(req.status, "routing_disputed") + + now = datetime.now(tz=timezone.utc) + req.status = "routing_disputed" + req.dispute_reason = body.reason + req.disputed_by = body.disputed_by + req.dispute_suggested_domain = body.suggested_domain + req.disputed_at = now + + dispute_entry = ( + f"disputed by {body.disputed_by}: {body.reason}" + + (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "") + ) + req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry + + # Notify custodian + _add_notification( + session, + from_agent=body.disputed_by, + to_agent="custodian", + subject=f"[routing-disputed] {req.title}", + body=( + f"**{body.disputed_by}** has disputed the routing of capability request " + f"**{req.title}**.\n\n" + f"**Reason:** {body.reason}\n" + + (f"**Suggested domain:** {body.suggested_domain}\n" if body.suggested_domain else "") + + f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}" + ), + ) + # Notify current fulfilling domain + if req.fulfilling_domain_slug: + _add_notification( + session, + from_agent=body.disputed_by, + to_agent=req.fulfilling_domain_slug, + subject=f"[routing-disputed] {req.title}", + body=( + f"The capability request **{req.title}** routed to your domain has been disputed " + f"by **{body.disputed_by}**.\n\n" + f"**Reason:** {body.reason}\n" + + (f"**Suggested domain:** {body.suggested_domain}" if body.suggested_domain else "") + ), + ) + + await session.commit() + await session.refresh(req) + return req + + +@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead) +async def reroute_request( + request_id: uuid.UUID, + body: CapabilityRequestReroute, + session: AsyncSession = Depends(get_session), +) -> CapabilityRequest: + """Re-route a disputed request to a new domain. Resets to requested.""" + req = await _get_request_or_404(request_id, session) + if req.status != "routing_disputed": + raise HTTPException( + status_code=422, + detail=f"Cannot reroute from status '{req.status}'. Only 'routing_disputed' requests can be rerouted.", + ) + if body.catalog_entry_id is None and body.domain is None: + raise HTTPException(status_code=422, detail="Either catalog_entry_id or domain must be provided.") + + if body.catalog_entry_id is not None: + entry = await session.get(CapabilityCatalog, body.catalog_entry_id) + if entry is None: + raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found") + req.catalog_entry_id = entry.id + req.fulfilling_domain_id = entry.domain_id + new_domain_slug = (await session.get(Domain, entry.domain_id)).slug if entry.domain_id else "unknown" + else: + new_domain = await _resolve_domain(body.domain, session) + req.fulfilling_domain_id = new_domain.id + new_domain_slug = new_domain.slug + + old_domain = req.dispute_suggested_domain or "unknown" + + # Clear dispute fields + req.dispute_reason = None + req.disputed_by = None + req.dispute_suggested_domain = None + req.disputed_at = None + req.status = "requested" + + reroute_entry = f"re-routed by {body.rerouted_by} → {new_domain_slug}: {body.note}" + req.routing_note = (req.routing_note + "\n" + reroute_entry) if req.routing_note else reroute_entry + + # Notify requester + _add_notification( + session, + from_agent=body.rerouted_by, + to_agent=req.requesting_agent, + subject=f"[re-routed] {req.title}", + body=( + f"Capability request **{req.title}** has been re-routed to **{new_domain_slug}**.\n\n" + f"**Note:** {body.note}" + ), + ) + # Notify new fulfilling domain + _add_notification( + session, + from_agent=body.rerouted_by, + to_agent=new_domain_slug, + subject=f"[capability-request] {req.title}", + body=( + f"Capability request **{req.title}** has been re-routed to your domain.\n\n" + f"**From:** {req.requesting_agent} ({req.requesting_domain_slug})\n" + f"**Type:** {req.capability_type}\n" + f"**Priority:** {req.priority}\n\n" + f"{req.description or '(no description)'}" + ), + ) + + await session.commit() + await session.refresh(req) + return req + + # --------------------------------------------------------------------------- # Routing algorithm # --------------------------------------------------------------------------- diff --git a/api/schemas/capability_request.py b/api/schemas/capability_request.py index 6c0c8e4..481ff98 100644 --- a/api/schemas/capability_request.py +++ b/api/schemas/capability_request.py @@ -62,6 +62,19 @@ class CapabilityRequestPatch(BaseModel): fulfilling_workstream_id: uuid.UUID | None = None +class CapabilityRequestDispute(BaseModel): + reason: str + disputed_by: str + suggested_domain: str | None = None + + +class CapabilityRequestReroute(BaseModel): + note: str + rerouted_by: str + domain: str | None = None # slug — used if catalog_entry_id not given + catalog_entry_id: uuid.UUID | None = None # preferred: re-derives domain + + class CapabilityRequestRead(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -81,6 +94,10 @@ class CapabilityRequestRead(BaseModel): catalog_entry_id: uuid.UUID | None = None resolution_note: str | None = None routing_note: str | None = None + dispute_reason: str | None = None + disputed_by: str | None = None + dispute_suggested_domain: str | None = None + disputed_at: datetime | None = None accepted_at: datetime | None = None completed_at: datetime | None = None created_at: datetime diff --git a/dashboard/src/capability-requests.md b/dashboard/src/capability-requests.md index f7782d2..741cc6b 100644 --- a/dashboard/src/capability-requests.md +++ b/dashboard/src/capability-requests.md @@ -48,7 +48,7 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/capabilities ```js // KPI sidebar -const open = requests.filter(r => ["requested","accepted","in_progress","ready_for_review"].includes(r.status)); +const open = requests.filter(r => ["requested","routing_disputed","accepted","in_progress","ready_for_review"].includes(r.status)); const completed = requests.filter(r => r.status === "completed"); const avgFulfill = completed.length > 0 ? (completed.reduce((s, r) => s + (new Date(r.completed_at) - new Date(r.created_at)), 0) / completed.length / 86400000).toFixed(1) @@ -74,7 +74,7 @@ const typeFilter = Inputs.select( {label: "Type", value: "all"} ); const statFilter = Inputs.select( - ["all", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"], + ["all", "routing_disputed", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"], {label: "Status", value: "all"} ); const domFilter = Inputs.select( @@ -102,6 +102,26 @@ const filtered = requests.filter(r => ```js const priorityColors = {critical: "#e53935", high: "orange", medium: "steelblue", low: "#aaa"}; +const disputed = requests.filter(r => r.status === "routing_disputed"); + +// Disputed banner — shown at top when any exist +if (disputed.length > 0) { + display(html`
`); +} + display(html`${requests.filter(r => r.status === "requested").length}
${requests.filter(r => ["accepted","in_progress"].includes(r.status)).length}