import re import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, status 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.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, CatalogCreate, CatalogPatch, CatalogRead, ) from hub_core.routers.capabilities import ( create_capability_request_read_router, create_capability_request_write_router, ) from api.services.repository_aliases import resolve_repository_slug # --------------------------------------------------------------------------- # 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 = APIRouter(tags=["capability-requests"]) async def _catalog_domain(slug: str, session: AsyncSession) -> Domain: domain = ( await session.execute(select(Domain).where(Domain.slug == slug)) ).scalar_one_or_none() if domain is None: raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found") return domain @router.post( "/capability-catalog/", response_model=CatalogRead, status_code=status.HTTP_201_CREATED, ) async def create_catalog_entry( body: CatalogCreate, session: AsyncSession = Depends(get_session), ) -> CapabilityCatalog: domain = await _catalog_domain(body.domain, session) repo_id = None if body.repo_slug: repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id entry = CapabilityCatalog( domain_id=domain.id, repo_id=repo_id, capability_type=body.capability_type, title=body.title, description=body.description, keywords=body.keywords, ) session.add(entry) try: await session.commit() except Exception: await session.rollback() raise HTTPException( status_code=409, detail=( f"Catalog entry '{body.title}' for type '{body.capability_type}' " f"already exists in domain '{body.domain}'" ), ) await session.refresh(entry) return entry @router.get("/capability-catalog/", response_model=list[CatalogRead]) async def list_catalog( domain: str | None = Query(None), capability_type: str | None = Query(None), status_filter: str | None = Query(None, alias="status"), repo_slug: str | None = Query(None), session: AsyncSession = Depends(get_session), ) -> list[CapabilityCatalog]: query = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc()) if domain: query = query.where( CapabilityCatalog.domain_id == (await _catalog_domain(domain, session)).id ) if capability_type: query = query.where(CapabilityCatalog.capability_type == capability_type) if repo_slug: query = query.where( CapabilityCatalog.repo_id == (await resolve_repository_slug(session, repo_slug)).repo.id ) if status_filter and status_filter != "all": query = query.where(CapabilityCatalog.status == status_filter) elif not status_filter: query = query.where(CapabilityCatalog.status == "active") return list((await session.execute(query)).scalars().all()) @router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead) async def patch_catalog_entry( entry_id: uuid.UUID, body: CatalogPatch, session: AsyncSession = Depends(get_session), ) -> CapabilityCatalog: entry = await session.get(CapabilityCatalog, entry_id) if entry is None: raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found") if body.repo_slug is not None: entry.repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id for field in ("description", "keywords", "status"): value = getattr(body, field) if value is not None: setattr(entry, field, value) await session.commit() await session.refresh(entry) return entry 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, ) )