VirtForemanBackend / app /services /tech_stack_service.py
Dirbol's picture
deploy: backend 2026-06-22T11:21:06Z
47fbc33
Raw
History Blame Contribute Delete
5.86 kB
"""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()),
)