feat(l1): match_or_build orchestrator + classify (match-first, gate-on-build)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
78
backend/app/services/match_or_build.py
Normal file
78
backend/app/services/match_or_build.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""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\":\"<key>\"} 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],
|
||||
ticket_ref: str, # passed through for caller/session use; not consumed here (Task 10)
|
||||
*,
|
||||
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}
|
||||
Reference in New Issue
Block a user