Server-assigns a uuid4 id to every AI-generated node (Finding 1 showstopper:
nodes had no id but the advance protocol keys on node_id, so ai_build walks
never advanced past question 1). Replaces the hidden {"node_type":"meta"}
walked_path convention with real category/problem_text/pending_node columns on
l1_walk_sessions (migration 61dda4f615c6) — fixes junk proposals + off-by-one
depth cap (Findings 8,9), and pending_node replays the served node on re-mount
(no duplicate paid LLM call). Intake honors explicit flow_id and adhoc=True
(Findings 4,5); flow_proposals.l1_session_id FK -> CASCADE (Finding 6 time
bomb); L1 category GET is owner+admin like PATCH and require_account_owner_or_admin
delegates to User.can_manage_account (Finding 7); escalate falls back to default
recipients + filters deleted_at + warns when empty (Finding 10). Cleanups: dead
ticket_ref removed, IntakeResponse per-outcome validator, unused acknowledged
dropped, escalations partial index, restored a deleted audit assertion.
Full Phase 2A backend set: 110 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
3.2 KiB
Python
78 lines
3.2 KiB
Python
"""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],
|
|
*,
|
|
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}
|