"""Tech-stack inference service — calls OpenRouter and returns validated model. If every upstream model is rate-limited or unreachable, we DO NOT hang and DO NOT bubble a 502 to the frontend. Instead we fall back to a deterministic "starter" tech-stack derived from keyword heuristics so the user gets actionable output immediately, plus a clear flag that says it's a stub. """ from __future__ import annotations import re import uuid from datetime import datetime, timezone from loguru import logger from app.ai.prompts import build_tech_stack_prompt from app.models.tech_stack import ( DailyWorkInput, InferredWorkResponse, TechStack, TechStackPayload, TechStep, ) from app.services.llm import LLMError, get_llm_client # Keyword-driven stub mapping. When the live OpenRouter call fails the user # still gets a meaningful, expandable baseline so the UI never dead-ends. _KEYWORD_STEPS: list[tuple[re.Pattern[str], tuple[str, str]]] = [ (re.compile(r"водопониж", re.I), ("Бурение скважин под водопонижение", "Бурение шурфов / водопонижающих скважин в осях, указанных заказчиком.")), (re.compile(r"фундамент", re.I), ("Устройство фундамента", "Земляные работы, устройство опалубки, армирование, бетонирование.")), (re.compile(r"плит", re.I), ("Устройство плиты перекрытия", "Монтаж опалубки, армокаркас, бетонирование с виброуплотнением, уход.")), (re.compile(r"кров", re.I), ("Устройство кровли", "Монтаж стропильной системы / пирога, гидроизоляция, контроль уклонов.")), (re.compile(r"стен", re.I), ("Возведение стен", "Кладка / монтаж элементов, утепление, контроль вертикали.")), (re.compile(r"бетон", re.I), ("Бетонирование конструкции", "Армирование, монтаж опалубки, укладка бетонной смеси, уход.")), ] def _fallback_payload(payload: DailyWorkInput, reason: str) -> TechStack: """Deterministic minimal tech-stack based on keyword matching. Ensures the response shape is contract-correct for the frontend even when all OpenRouter models are rate-limited. Marked with a hint so the UI can warn the user that AI enrichment is pending. """ steps: list[TechStep] = [] for pat, (title, description) in _KEYWORD_STEPS: if pat.search(payload.raw_text): steps.append(TechStep( order=len(steps) + 1, title=title, description=description, required_acts=["АОСР", "Общий журнал работ"], weather_sensitive=True, )) if not steps: steps.append(TechStep( order=1, title="Базовый тех-стек по описанию пользователя", description=( "ИИ-модели временно недоступны; приведена минимальная структура." ), required_acts=["Общий журнал работ"], )) inferred_summary = ( f"Распознан базовый набор этапов из описания «{payload.raw_text}». " f"AI-обогащение недоступно ({reason}); уточните параметры для детализации." ) return TechStackPayload( inferred_summary=inferred_summary, steps=steps, required_documents=["Общий журнал работ"], missing_info_prompts=[ "Уточните точные оси / отметки / объёмы работ.", "Подтвердите температурный режим работ (если наружные).", "При наличии — пришлите рабочий проект / чертёж PDF.", ], ) async def infer_tech_stack(payload: DailyWorkInput) -> InferredWorkResponse: """Run inference on user's daily-work input and return structured tech-stack. Behaviour: 1. Try the OpenRouter chain (5 models, hard 25-s budget). 2. On total failure → deterministic stub so the UI never hangs. """ system_prompt, user_prompt = build_tech_stack_prompt( raw_text=payload.raw_text, date_from=payload.date_from.isoformat(), date_to=payload.date_to.isoformat(), axes=payload.axes, ) parsed: TechStackPayload | None = None model_used = "fallback:keyword-stub" try: client = get_llm_client() parsed, model_used = await client.structured_completion( schema=TechStackPayload, system_prompt=system_prompt, user_prompt=user_prompt, ) except LLMError as exc: logger.warning( f"[infer] chain exhausted → switching to keyword stub ({exc})" ) parsed = _fallback_payload(payload, reason="LLM upstream rate-limited") tech_stack = TechStack( source_input=payload.raw_text, inferred_summary=parsed.inferred_summary, steps=parsed.steps, required_documents=parsed.required_documents, missing_info_prompts=parsed.missing_info_prompts, model_used=model_used, generated_at=datetime.now(timezone.utc).isoformat(), ) return InferredWorkResponse( tech_stack=tech_stack, request_id=str(uuid.uuid4()), )