state-hub/api/routers/capability_requests.py
tegwick f2e042a278 Finish CUST-WP-0011 and implement STATE-WP-0061 suggestion backlog.
Add persisted Suggestion entities with WSJF ranking, relevance bumps,
REST/MCP write surfaces, /suggestions dashboard page, and daily triage
digest integration. Document the cluster operating model, archive the
completed migration workplan, and seed WARDEN-WP-0012 routing scenarios.
2026-07-06 10:52:49 +02:00

435 lines
No EOL
15 KiB
Python

import re
import uuid
from datetime import datetime, timezone
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
from api.models.agent_message import AgentMessage
from api.models.capability_catalog import CapabilityCatalog
from api.models.capability_request import CapabilityRequest
from api.models.domain import Domain
from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.services.suggestion_relevance import bump_matching_for_capability_request
from api.schemas.capability_request import (
CapabilityRequestAccept,
CapabilityRequestCreate,
CapabilityRequestDispute,
CapabilityRequestPatch,
CapabilityRequestRead,
CapabilityRequestReroute,
CapabilityRequestStatusPatch,
)
from hub_core.routers.capabilities import (
create_capability_catalog_router,
create_capability_request_read_router,
create_capability_request_write_router,
)
# ---------------------------------------------------------------------------
# Write-router callbacks
# ---------------------------------------------------------------------------
async def _route_capability(
session: AsyncSession,
body: CapabilityRequestCreate,
) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]:
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability_match(
session,
body.capability_type,
body.title,
body.description or "",
)
return fulfilling_domain_id, catalog_entry_id, routing_note
def _build_capability_request(
body: CapabilityRequestCreate,
requesting_domain: Domain,
fulfilling_domain_id: uuid.UUID | None,
catalog_entry_id: uuid.UUID | None,
routing_note: str | None,
) -> CapabilityRequest:
return CapabilityRequest(
title=body.title,
description=body.description,
capability_type=body.capability_type,
priority=body.priority,
requesting_domain_id=requesting_domain.id,
requesting_agent=body.requesting_agent,
requesting_workplan_id=body.requesting_workplan_id,
blocking_task_id=body.blocking_task_id,
fulfilling_domain_id=fulfilling_domain_id,
catalog_entry_id=catalog_entry_id,
routing_note=routing_note,
)
async def _on_capability_request_persisted(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestCreate,
) -> None:
await session.flush()
await bump_matching_for_capability_request(
session,
request_id=req.id,
title=body.title,
description=body.description or "",
capability_type=body.capability_type,
catalog_entry_id=req.catalog_entry_id,
)
await _notify_on_create(session, req, body)
async def _notify_on_create(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestCreate,
) -> None:
await session.flush()
if req.fulfilling_domain_id:
ful_domain = await session.get(Domain, req.fulfilling_domain_id)
to_agent = ful_domain.slug if ful_domain else "broadcast"
else:
to_agent = "broadcast"
_add_notification(
session,
from_agent="system",
to_agent=to_agent,
subject=f"[capability-request] {body.title}",
body=(
f"New capability request from **{body.requesting_agent}** "
f"({body.requesting_domain}):\n\n"
f"**Type:** {body.capability_type}\n"
f"**Priority:** {body.priority}\n\n"
f"{body.description or '(no description)'}"
),
)
def _apply_accept_fields(req: CapabilityRequest, body: CapabilityRequestAccept) -> None:
req.fulfilling_workplan_id = body.fulfilling_workplan_id
async def _notify_on_accept(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestAccept,
) -> None:
_add_notification(
session,
from_agent=body.fulfilling_agent,
to_agent=req.requesting_agent,
subject=f"[capability-accepted] {req.title}",
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
)
async def _on_status_change(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestStatusPatch,
now: datetime,
) -> None:
if body.status == "completed":
if req.blocking_task_id:
task = await session.get(Task, req.blocking_task_id)
if task and task.status == "wait":
task.status = "todo"
task.blocking_reason = None
_add_notification(
session,
from_agent="system",
to_agent=req.requesting_agent,
subject=f"[capability-completed] {req.title}",
body=(
f"Capability request **{req.title}** has been completed.\n\n"
f"{body.note or ''}"
),
)
elif body.status == "ready_for_review":
_add_notification(
session,
from_agent=req.fulfilling_agent or "system",
to_agent=req.requesting_agent,
subject=f"[capability-ready] {req.title} -- please review",
body=(
f"Capability **{req.title}** is ready for your review and optimization.\n\n"
f"{body.note or ''}"
),
)
elif body.status == "rejected":
_add_notification(
session,
from_agent=req.fulfilling_agent or "system",
to_agent=req.requesting_agent,
subject=f"[capability-rejected] {req.title}",
body=(
f"Capability request **{req.title}** has been rejected.\n\n"
f"**Reason:** {body.note or '(no reason given)'}"
),
)
elif body.status == "in_progress":
_add_notification(
session,
from_agent=req.fulfilling_agent or "system",
to_agent=req.requesting_agent,
subject=f"[capability-in-progress] {req.title}",
body=f"Work on capability **{req.title}** is now in progress.",
)
async def _apply_capability_patch(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestPatch,
) -> bool:
corrections: list[str] = []
if body.catalog_entry_id is not None:
old_entry_id = req.catalog_entry_id
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
corrections.append(
f"catalog_entry: {old_entry_id}{entry.id} ({entry.title}); "
f"fulfilling_domain re-derived → {entry.domain_id}"
)
if body.priority is not None:
req.priority = body.priority
corrections.append(f"priority → {body.priority}")
if body.blocking_task_id is not None:
req.blocking_task_id = body.blocking_task_id
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
if body.fulfilling_workplan_id is not None:
req.fulfilling_workplan_id = body.fulfilling_workplan_id
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
if not corrections:
return False
correction_note = "hub correction: " + "; ".join(corrections)
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
return True
async def _notify_on_dispute(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestDispute,
now: datetime,
) -> None:
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
_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'}"
),
)
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 "")
),
)
async def _notify_on_reroute(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestReroute,
new_domain_slug: str,
) -> None:
_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}"
),
)
_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)'}"
),
)
# ---------------------------------------------------------------------------
# Routing algorithm
# ---------------------------------------------------------------------------
async def _route_capability_match(
session: AsyncSession,
capability_type: str,
title: str,
description: str,
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
"""Find the best-matching catalog entry for a capability request.
Returns (domain_id, catalog_entry_id, routing_note).
"""
q = select(CapabilityCatalog).where(
CapabilityCatalog.capability_type == capability_type,
CapabilityCatalog.status == "active",
)
entries = list((await session.execute(q)).scalars().all())
if not entries:
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
if len(entries) == 1:
entry = entries[0]
return entry.domain_id, entry.id, f"single match: '{entry.title}' (domain={entry.domain_id})"
combined = f"{title} {description or ''}".lower()
scored: list[tuple[int, CapabilityCatalog]] = []
for entry in entries:
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
score = sum(
1 for kw in keywords
if re.search(r"\b" + re.escape(kw.lower()) + r"\b", combined)
)
scored.append((score, entry))
scored.sort(key=lambda item: -item[0])
best_score, best = scored[0]
if best_score == 0:
return None, None, (
f"no keyword overlap for type '{capability_type}' among "
f"{len(entries)} entries — broadcast"
)
if len(scored) >= 2 and scored[1][0] == best_score:
return None, None, (
f"ambiguous routing: '{scored[0][1].title}' and '{scored[1][1].title}' "
f"both scored {best_score} — broadcast"
)
return best.domain_id, best.id, (
f"matched '{best.title}' (score={best_score}, "
f"keywords matched from: {title!r})"
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _add_notification(
session: AsyncSession,
from_agent: str,
to_agent: str,
subject: str,
body: str,
) -> None:
msg = AgentMessage(
from_agent=from_agent,
to_agent=to_agent,
subject=subject,
body=body,
)
session.add(msg)
def _check_transition(current: str, target: str) -> None:
can_reach, failures, flow_result = evaluate_transition(
"capability_request",
current,
target,
)
if not can_reach:
raise HTTPException(
status_code=422,
detail={
"message": f"Cannot transition from '{current}' to '{target}'.",
"current_workstation": current,
"target_workstation": target,
"blocking_assertions": [
assertion_result_to_dict(item) for item in failures
],
"flow_result": flow_result_to_dict(flow_result),
},
)
router = create_capability_catalog_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
catalog_model=CapabilityCatalog,
)
router.include_router(
create_capability_request_read_router(
get_session,
domain_model=Domain,
request_model=CapabilityRequest,
request_read_schema=CapabilityRequestRead,
)
)
router.include_router(
create_capability_request_write_router(
get_session,
domain_model=Domain,
catalog_model=CapabilityCatalog,
request_model=CapabilityRequest,
request_create_schema=CapabilityRequestCreate,
request_accept_schema=CapabilityRequestAccept,
request_patch_schema=CapabilityRequestPatch,
request_status_patch_schema=CapabilityRequestStatusPatch,
request_dispute_schema=CapabilityRequestDispute,
request_reroute_schema=CapabilityRequestReroute,
request_read_schema=CapabilityRequestRead,
route_request=_route_capability,
build_request=_build_capability_request,
on_request_persisted=_on_capability_request_persisted,
check_transition=_check_transition,
apply_accept_fields=_apply_accept_fields,
after_accept=_notify_on_accept,
after_status_change=_on_status_change,
apply_patch=_apply_capability_patch,
after_dispute=_notify_on_dispute,
after_reroute=_notify_on_reroute,
)
)