Spaces:
Running
Running
| """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()), | |
| ) | |