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.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 _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=_notify_on_create, 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, ) )