"""Intake orchestrator: match published flows first, gate generic build behind the account's enabled categories (spec §3). Match runs BEFORE the category gate so an authored flow is never blocked by category settings (Finding 4).""" import logging import re from typing import Any, Optional from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession from app.core.ai_provider import get_ai_provider from app.core.config import settings from app.services import flow_matching_engine from app.services.l1_category_service import ( DEFAULT_L1_CATEGORIES, get_enabled_categories, is_category_enabled, ) from app.services.llm_utils import parse_llm_json logger = logging.getLogger(__name__) MATCH_THRESHOLD = 0.75 # spec §5.3 SUGGEST_THRESHOLD = 0.60 # spec §5.3 _CLASSIFY_PROMPT = ( "Classify the IT support problem into exactly one of these category keys, " "or 'unknown'. Return JSON {\"category\":\"\"} only.\nKEYS: " + ", ".join(DEFAULT_L1_CATEGORIES) ) async def classify(problem_text: str) -> str: """Map a problem to a category key via a short model call; keyword fallback.""" try: provider = get_ai_provider(settings.get_model_for_action("l1_classify")) raw, _, _ = await provider.generate_json( system_prompt=_CLASSIFY_PROMPT, messages=[{"role": "user", "content": problem_text}], max_tokens=64, ) cat = parse_llm_json(raw).get("category", "unknown") return cat if cat in DEFAULT_L1_CATEGORIES else "unknown" except Exception as e: # noqa: BLE001 — fall back, never hard-fail intake logger.warning("classify model call failed (%s); keyword fallback", e) text = problem_text.lower() for cat in DEFAULT_L1_CATEGORIES: if any(re.search(rf"\b{re.escape(tok)}\b", text) for tok in cat.split("_")): return cat return "unknown" async def match_or_build( account_id: UUID, problem_text: str, problem_domain: Optional[str], *, db: AsyncSession, force_build: bool = False, ) -> dict[str, Any]: if not force_build: hits = await flow_matching_engine.find_matches( problem_text, problem_domain, account_id, db) best = max(hits, key=lambda h: h["score"], default=None) if hits else None # find_matches returns tree_id as a UUID object; normalize the public # contract to str so callers can re-parse with UUID(...) without TypeError. if best and best["score"] >= MATCH_THRESHOLD: return {"outcome": "matched", "flow_id": str(best["tree_id"]), "session_kind": "flow"} if best and best["score"] >= SUGGEST_THRESHOLD: return {"outcome": "suggest", "near_miss": {"flow_id": str(best["tree_id"]), "flow_name": best["tree_name"], "score": best["score"]}, "can_build": True} category = await classify(problem_text) enabled = await get_enabled_categories(account_id, db) if not is_category_enabled(category, enabled): return {"outcome": "out_of_scope", "category": category} return {"outcome": "build", "session_kind": "ai_build", "category": category}