Spaces:
Running
Running
deploy: backend 2026-06-22T08:22:30Z
Browse files- .env.example +4 -2
- app/core/config.py +3 -3
- app/services/openrouter.py +115 -52
- app/services/tech_stack_service.py +90 -6
- tests/test_stub_fallback.py +58 -0
.env.example
CHANGED
|
@@ -7,9 +7,11 @@
|
|
| 7 |
# --- OpenRouter ---
|
| 8 |
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
| 9 |
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
| 10 |
-
OPENROUTER_FREE_MODEL_PRIMARY=
|
| 11 |
# Comma-separated chain of fallback free models. Tried in order if primary fails.
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# --- Hugging Face Space (used for HTTP-Referer header) ---
|
| 15 |
HF_SPACE_URL=https://huggingface.co/spaces/Dirbol/VirtForemanBackend
|
|
|
|
| 7 |
# --- OpenRouter ---
|
| 8 |
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
| 9 |
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
| 10 |
+
OPENROUTER_FREE_MODEL_PRIMARY=openai/gpt-oss-20b:free
|
| 11 |
# Comma-separated chain of fallback free models. Tried in order if primary fails.
|
| 12 |
+
# Keep this list short so an inference call finishes inside HF Space's
|
| 13 |
+
# request budget (~60 s on free tier).
|
| 14 |
+
OPENROUTER_FREE_MODEL_FALLBACKS=qwen/qwen3-next-80b-a3b-instruct:free,google/gemma-4-31b-it:free
|
| 15 |
|
| 16 |
# --- Hugging Face Space (used for HTTP-Referer header) ---
|
| 17 |
HF_SPACE_URL=https://huggingface.co/spaces/Dirbol/VirtForemanBackend
|
app/core/config.py
CHANGED
|
@@ -19,14 +19,14 @@ class Settings(BaseSettings):
|
|
| 19 |
openrouter_base_url: str = Field(default="https://openrouter.ai/api/v1")
|
| 20 |
# Primary + comma-separated list of fallback free models.
|
| 21 |
# All must match OpenRouter's `:free` endpoint convention.
|
|
|
|
|
|
|
| 22 |
openrouter_free_model_primary: str = Field(
|
| 23 |
-
default="
|
| 24 |
)
|
| 25 |
openrouter_free_model_fallbacks: str = Field(
|
| 26 |
default=(
|
| 27 |
"qwen/qwen3-next-80b-a3b-instruct:free,"
|
| 28 |
-
"nvidia/nemotron-3-super-120b-a12b:free,"
|
| 29 |
-
"openai/gpt-oss-20b:free,"
|
| 30 |
"google/gemma-4-31b-it:free"
|
| 31 |
)
|
| 32 |
)
|
|
|
|
| 19 |
openrouter_base_url: str = Field(default="https://openrouter.ai/api/v1")
|
| 20 |
# Primary + comma-separated list of fallback free models.
|
| 21 |
# All must match OpenRouter's `:free` endpoint convention.
|
| 22 |
+
# Default chain kept short so the inference call always finishes well
|
| 23 |
+
# inside HF Space's request budget (~60 s free tier).
|
| 24 |
openrouter_free_model_primary: str = Field(
|
| 25 |
+
default="openai/gpt-oss-20b:free"
|
| 26 |
)
|
| 27 |
openrouter_free_model_fallbacks: str = Field(
|
| 28 |
default=(
|
| 29 |
"qwen/qwen3-next-80b-a3b-instruct:free,"
|
|
|
|
|
|
|
| 30 |
"google/gemma-4-31b-it:free"
|
| 31 |
)
|
| 32 |
)
|
app/services/openrouter.py
CHANGED
|
@@ -1,25 +1,25 @@
|
|
| 1 |
"""OpenRouter async client with structured-output Pydantic enforcement.
|
| 2 |
|
| 3 |
-
Free models on OpenRouter have rate limits and occasional
|
| 4 |
-
|
| 5 |
-
-
|
| 6 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 11 |
import json
|
|
|
|
| 12 |
from typing import Any, Type, TypeVar
|
| 13 |
|
| 14 |
import httpx
|
| 15 |
from loguru import logger
|
| 16 |
from pydantic import BaseModel, ValidationError
|
| 17 |
-
from tenacity import (
|
| 18 |
-
retry,
|
| 19 |
-
retry_if_exception_type,
|
| 20 |
-
stop_after_attempt,
|
| 21 |
-
wait_exponential,
|
| 22 |
-
)
|
| 23 |
|
| 24 |
from app.core.config import get_settings
|
| 25 |
|
|
@@ -27,20 +27,31 @@ T = TypeVar("T", bound=BaseModel)
|
|
| 27 |
|
| 28 |
|
| 29 |
class OpenRouterError(Exception):
|
| 30 |
-
"""Raised when OpenRouter fails after
|
| 31 |
|
| 32 |
|
| 33 |
class _StructuredResponseError(Exception):
|
| 34 |
-
"""Internal: model returned
|
| 35 |
|
| 36 |
|
| 37 |
class OpenRouterClient:
|
| 38 |
"""Async client wrapping OpenRouter chat completions."""
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
def __init__(self, http_client: httpx.AsyncClient | None = None) -> None:
|
| 41 |
self.settings = get_settings()
|
| 42 |
self._client = http_client
|
| 43 |
self._owns_client = http_client is None
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
def _headers(self) -> dict[str, str]:
|
| 46 |
return {
|
|
@@ -55,7 +66,7 @@ class OpenRouterClient:
|
|
| 55 |
self._client = httpx.AsyncClient(
|
| 56 |
base_url=self.settings.openrouter_base_url,
|
| 57 |
headers=self._headers(),
|
| 58 |
-
timeout=
|
| 59 |
)
|
| 60 |
return self._client
|
| 61 |
|
|
@@ -64,22 +75,14 @@ class OpenRouterClient:
|
|
| 64 |
await self._client.aclose()
|
| 65 |
self._client = None
|
| 66 |
|
| 67 |
-
|
| 68 |
-
retry=retry_if_exception_type(
|
| 69 |
-
(httpx.HTTPError, _StructuredResponseError)
|
| 70 |
-
),
|
| 71 |
-
stop=stop_after_attempt(2),
|
| 72 |
-
wait=wait_exponential(multiplier=1, min=2, max=8),
|
| 73 |
-
reraise=True,
|
| 74 |
-
)
|
| 75 |
-
async def _call_model(
|
| 76 |
self,
|
| 77 |
*,
|
| 78 |
model: str,
|
| 79 |
system_prompt: str,
|
| 80 |
user_prompt: str,
|
| 81 |
) -> str:
|
| 82 |
-
"""
|
| 83 |
client = await self._ensure_client()
|
| 84 |
payload: dict[str, Any] = {
|
| 85 |
"model": model,
|
|
@@ -91,15 +94,27 @@ class OpenRouterClient:
|
|
| 91 |
],
|
| 92 |
"response_format": {"type": "json_object"},
|
| 93 |
}
|
| 94 |
-
logger.
|
| 95 |
resp = await client.post("/chat/completions", json=payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
resp.raise_for_status()
|
|
|
|
| 97 |
data = resp.json()
|
| 98 |
try:
|
| 99 |
return data["choices"][0]["message"]["content"]
|
| 100 |
except (KeyError, IndexError, TypeError) as exc:
|
| 101 |
raise _StructuredResponseError(
|
| 102 |
-
f"
|
| 103 |
) from exc
|
| 104 |
|
| 105 |
async def structured_completion(
|
|
@@ -109,16 +124,13 @@ class OpenRouterClient:
|
|
| 109 |
system_prompt: str,
|
| 110 |
user_prompt: str,
|
| 111 |
model_override: str | None = None,
|
|
|
|
| 112 |
) -> tuple[T, str]:
|
| 113 |
"""Return validated Pydantic instance + model name used.
|
| 114 |
|
| 115 |
-
Iterates
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
Retryable errors (`httpx.HTTPError`, `_StructuredResponseError`,
|
| 119 |
-
`pydantic.ValidationError`) cause a move to the next model in the chain.
|
| 120 |
-
|
| 121 |
-
Raises OpenRouterError if the entire chain fails.
|
| 122 |
"""
|
| 123 |
chain: list[str] = []
|
| 124 |
if model_override:
|
|
@@ -128,42 +140,93 @@ class OpenRouterClient:
|
|
| 128 |
if fb not in chain:
|
| 129 |
chain.append(fb)
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
for attempt in range(1, 3):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
try:
|
| 138 |
-
raw = await
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
| 142 |
)
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
except _StructuredResponseError as exc:
|
|
|
|
| 145 |
logger.warning(
|
| 146 |
-
f"[openrouter] {model_name} attempt {attempt}
|
| 147 |
)
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
+ "\n\nReturn ONLY valid JSON
|
| 151 |
)
|
| 152 |
-
|
|
|
|
|
|
|
| 153 |
except (httpx.HTTPStatusError, ValidationError) as exc:
|
|
|
|
| 154 |
logger.warning(
|
| 155 |
-
f"[openrouter] {model_name} attempt {attempt}
|
|
|
|
| 156 |
)
|
| 157 |
break # move to next model
|
| 158 |
except httpx.HTTPError as exc:
|
|
|
|
| 159 |
logger.warning(
|
| 160 |
-
f"[openrouter] {model_name} attempt {attempt}
|
|
|
|
| 161 |
)
|
| 162 |
-
break
|
| 163 |
|
| 164 |
raise OpenRouterError(
|
| 165 |
-
f"
|
| 166 |
-
f"Tried: {sorted(tried)}"
|
| 167 |
)
|
| 168 |
|
| 169 |
|
|
|
|
| 1 |
"""OpenRouter async client with structured-output Pydantic enforcement.
|
| 2 |
|
| 3 |
+
Free models on OpenRouter have aggressive rate limits (429) and occasional
|
| 4 |
+
downtime. This client:
|
| 5 |
+
- Sets the required headers (Authorization, HTTP-Referer, X-Title).
|
| 6 |
+
- Tries models in a fixed chain (primary → fallbacks).
|
| 7 |
+
- **One attempt per model** (no internal retry) — the chain itself provides
|
| 8 |
+
redundancy within a tight wall-clock budget.
|
| 9 |
+
- Hard wall-clock budget (default 25 s) so the request never hangs.
|
| 10 |
+
- Forces JSON output via `response_format`; validates with Pydantic.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
import asyncio
|
| 16 |
import json
|
| 17 |
+
import time
|
| 18 |
from typing import Any, Type, TypeVar
|
| 19 |
|
| 20 |
import httpx
|
| 21 |
from loguru import logger
|
| 22 |
from pydantic import BaseModel, ValidationError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
from app.core.config import get_settings
|
| 25 |
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
class OpenRouterError(Exception):
|
| 30 |
+
"""Raised when OpenRouter fails after the full chain exhausts."""
|
| 31 |
|
| 32 |
|
| 33 |
class _StructuredResponseError(Exception):
|
| 34 |
+
"""Internal: model returned text that did not match the requested schema."""
|
| 35 |
|
| 36 |
|
| 37 |
class OpenRouterClient:
|
| 38 |
"""Async client wrapping OpenRouter chat completions."""
|
| 39 |
|
| 40 |
+
# Per-attempt network budget. Connections fail fast (5 s), full read in 15 s.
|
| 41 |
+
DEFAULT_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
|
| 42 |
+
# Total wall-clock budget for the entire `structured_completion()` call.
|
| 43 |
+
# Hugging Face Spaces free tier typically idles / kills workers after ~60 s.
|
| 44 |
+
DEFAULT_BUDGET_S = 25.0
|
| 45 |
+
# How long to remember a rate-limit (429) per model so we don't hammer it.
|
| 46 |
+
RATE_LIMIT_COOLDOWN_S = 60.0
|
| 47 |
+
|
| 48 |
def __init__(self, http_client: httpx.AsyncClient | None = None) -> None:
|
| 49 |
self.settings = get_settings()
|
| 50 |
self._client = http_client
|
| 51 |
self._owns_client = http_client is None
|
| 52 |
+
# Per-model ban list with deadline epoch. A model on the list is
|
| 53 |
+
# skipped during structured_completion for `RATE_LIMIT_COOLDOWN_S` s.
|
| 54 |
+
self._cooldown_until: dict[str, float] = {}
|
| 55 |
|
| 56 |
def _headers(self) -> dict[str, str]:
|
| 57 |
return {
|
|
|
|
| 66 |
self._client = httpx.AsyncClient(
|
| 67 |
base_url=self.settings.openrouter_base_url,
|
| 68 |
headers=self._headers(),
|
| 69 |
+
timeout=self.DEFAULT_TIMEOUT,
|
| 70 |
)
|
| 71 |
return self._client
|
| 72 |
|
|
|
|
| 75 |
await self._client.aclose()
|
| 76 |
self._client = None
|
| 77 |
|
| 78 |
+
async def _call_model_once(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
self,
|
| 80 |
*,
|
| 81 |
model: str,
|
| 82 |
system_prompt: str,
|
| 83 |
user_prompt: str,
|
| 84 |
) -> str:
|
| 85 |
+
"""Single non-retrying attempt against one OpenRouter model."""
|
| 86 |
client = await self._ensure_client()
|
| 87 |
payload: dict[str, Any] = {
|
| 88 |
"model": model,
|
|
|
|
| 94 |
],
|
| 95 |
"response_format": {"type": "json_object"},
|
| 96 |
}
|
| 97 |
+
logger.info(f"[openrouter] POST {model}")
|
| 98 |
resp = await client.post("/chat/completions", json=payload)
|
| 99 |
+
|
| 100 |
+
if resp.status_code == 429:
|
| 101 |
+
# Ban this model for RATE_LIMIT_COOLDOWN_S seconds.
|
| 102 |
+
self._cooldown_until[model] = time.monotonic() + self.RATE_LIMIT_COOLDOWN_S
|
| 103 |
+
raise httpx.HTTPStatusError(
|
| 104 |
+
"rate-limited (429)", request=resp.request, response=resp
|
| 105 |
+
)
|
| 106 |
+
if resp.status_code == 404:
|
| 107 |
+
raise httpx.HTTPStatusError(
|
| 108 |
+
"model unavailable (404)", request=resp.request, response=resp
|
| 109 |
+
)
|
| 110 |
resp.raise_for_status()
|
| 111 |
+
|
| 112 |
data = resp.json()
|
| 113 |
try:
|
| 114 |
return data["choices"][0]["message"]["content"]
|
| 115 |
except (KeyError, IndexError, TypeError) as exc:
|
| 116 |
raise _StructuredResponseError(
|
| 117 |
+
f"unexpected response shape: {exc}"
|
| 118 |
) from exc
|
| 119 |
|
| 120 |
async def structured_completion(
|
|
|
|
| 124 |
system_prompt: str,
|
| 125 |
user_prompt: str,
|
| 126 |
model_override: str | None = None,
|
| 127 |
+
budget_s: float | None = None,
|
| 128 |
) -> tuple[T, str]:
|
| 129 |
"""Return validated Pydantic instance + model name used.
|
| 130 |
|
| 131 |
+
Iterates the model chain inside a hard wall-clock budget. **One
|
| 132 |
+
attempt per model** — chain itself handles fallbacks. Stops as soon
|
| 133 |
+
as the budget is drained.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
"""
|
| 135 |
chain: list[str] = []
|
| 136 |
if model_override:
|
|
|
|
| 140 |
if fb not in chain:
|
| 141 |
chain.append(fb)
|
| 142 |
|
| 143 |
+
now = time.monotonic()
|
| 144 |
+
# Filter out models that are still in cooldown from prior 429s.
|
| 145 |
+
active_chain = [
|
| 146 |
+
m for m in chain
|
| 147 |
+
if self._cooldown_until.get(m, 0.0) <= now
|
| 148 |
+
]
|
| 149 |
+
if active_chain != chain:
|
| 150 |
+
skipped = [m for m in chain if m not in active_chain]
|
| 151 |
+
logger.info(
|
| 152 |
+
f"[openrouter] skipping {len(skipped)} model(s) in cooldown: "
|
| 153 |
+
f"{skipped}"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
budget = budget_s or self.DEFAULT_BUDGET_S
|
| 157 |
+
deadline = time.monotonic() + budget
|
| 158 |
+
tried: list[str] = []
|
| 159 |
+
failures: list[str] = []
|
| 160 |
+
|
| 161 |
+
for model_name in active_chain:
|
| 162 |
+
elapsed = time.monotonic()
|
| 163 |
+
if elapsed >= deadline:
|
| 164 |
+
logger.warning(
|
| 165 |
+
f"[openrouter] budget exhausted after {len(tried)} model(s)"
|
| 166 |
+
)
|
| 167 |
+
break
|
| 168 |
+
tried.append(model_name)
|
| 169 |
+
# Loosen the JSON-mandate reminder if we are on attempt 2+ for
|
| 170 |
+
# this model (used by the schema-retry case below).
|
| 171 |
+
current_user_prompt = user_prompt
|
| 172 |
for attempt in range(1, 3):
|
| 173 |
+
per_attempt_deadline = min(
|
| 174 |
+
deadline,
|
| 175 |
+
elapsed + (deadline - elapsed) / max(1, len(chain) - len(tried) + 1),
|
| 176 |
+
)
|
| 177 |
+
remaining = per_attempt_deadline - time.monotonic()
|
| 178 |
+
if remaining <= 0:
|
| 179 |
+
break
|
| 180 |
try:
|
| 181 |
+
raw = await asyncio.wait_for(
|
| 182 |
+
self._call_model_once(
|
| 183 |
+
model=model_name,
|
| 184 |
+
system_prompt=system_prompt,
|
| 185 |
+
user_prompt=current_user_prompt,
|
| 186 |
+
),
|
| 187 |
+
timeout=remaining,
|
| 188 |
)
|
| 189 |
+
instance = schema.model_validate_json(raw)
|
| 190 |
+
logger.info(
|
| 191 |
+
f"[openrouter] success: model={model_name}, "
|
| 192 |
+
f"attempt={attempt}, elapsed={time.monotonic()-deadline+budget:.2f}s"
|
| 193 |
+
)
|
| 194 |
+
return instance, model_name
|
| 195 |
+
except asyncio.TimeoutError:
|
| 196 |
+
failures.append(f"{model_name}: budget timeout")
|
| 197 |
+
logger.warning(
|
| 198 |
+
f"[openrouter] {model_name} attempt {attempt}: timeout"
|
| 199 |
+
)
|
| 200 |
+
break # move to next model
|
| 201 |
except _StructuredResponseError as exc:
|
| 202 |
+
failures.append(f"{model_name}: bad shape — {exc}")
|
| 203 |
logger.warning(
|
| 204 |
+
f"[openrouter] {model_name} attempt {attempt}: bad shape: {exc}"
|
| 205 |
)
|
| 206 |
+
# Re-prompt with the JSON-only suffix once, then move on.
|
| 207 |
+
current_user_prompt = (
|
| 208 |
+
user_prompt + "\n\nReturn ONLY valid JSON, no commentary."
|
| 209 |
)
|
| 210 |
+
if attempt == 1:
|
| 211 |
+
continue
|
| 212 |
+
break
|
| 213 |
except (httpx.HTTPStatusError, ValidationError) as exc:
|
| 214 |
+
failures.append(f"{model_name}: {type(exc).__name__} — {exc}")
|
| 215 |
logger.warning(
|
| 216 |
+
f"[openrouter] {model_name} attempt {attempt}: "
|
| 217 |
+
f"{type(exc).__name__} — {exc}"
|
| 218 |
)
|
| 219 |
break # move to next model
|
| 220 |
except httpx.HTTPError as exc:
|
| 221 |
+
failures.append(f"{model_name}: transport — {exc}")
|
| 222 |
logger.warning(
|
| 223 |
+
f"[openrouter] {model_name} attempt {attempt}: "
|
| 224 |
+
f"transport error — {exc}"
|
| 225 |
)
|
| 226 |
+
break
|
| 227 |
|
| 228 |
raise OpenRouterError(
|
| 229 |
+
f"OpenRouter chain exhausted. Tried={tried[:5]}; failures={failures[:5]}"
|
|
|
|
| 230 |
)
|
| 231 |
|
| 232 |
|
app/services/tech_stack_service.py
CHANGED
|
@@ -1,7 +1,14 @@
|
|
| 1 |
-
"""Tech-stack inference service — calls OpenRouter and returns validated model.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import uuid
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
|
|
@@ -13,13 +20,86 @@ from app.models.tech_stack import (
|
|
| 13 |
InferredWorkResponse,
|
| 14 |
TechStack,
|
| 15 |
TechStackPayload,
|
|
|
|
| 16 |
)
|
| 17 |
from app.services.openrouter import OpenRouterError, get_openrouter_client
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
async def infer_tech_stack(payload: DailyWorkInput) -> InferredWorkResponse:
|
| 21 |
-
"""Run inference on user's daily-work input and return structured tech-stack.
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
system_prompt, user_prompt = build_tech_stack_prompt(
|
| 24 |
raw_text=payload.raw_text,
|
| 25 |
date_from=payload.date_from.isoformat(),
|
|
@@ -27,16 +107,20 @@ async def infer_tech_stack(payload: DailyWorkInput) -> InferredWorkResponse:
|
|
| 27 |
axes=payload.axes,
|
| 28 |
)
|
| 29 |
|
|
|
|
|
|
|
| 30 |
try:
|
| 31 |
-
|
| 32 |
parsed, model_used = await client.structured_completion(
|
| 33 |
schema=TechStackPayload,
|
| 34 |
system_prompt=system_prompt,
|
| 35 |
user_prompt=user_prompt,
|
| 36 |
)
|
| 37 |
except OpenRouterError as exc:
|
| 38 |
-
logger.
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
tech_stack = TechStack(
|
| 42 |
source_input=payload.raw_text,
|
|
|
|
| 1 |
+
"""Tech-stack inference service — calls OpenRouter and returns validated model.
|
| 2 |
+
|
| 3 |
+
If every upstream model is rate-limited or unreachable, we DO NOT hang and
|
| 4 |
+
DO NOT bubble a 502 to the frontend. Instead we fall back to a deterministic
|
| 5 |
+
"starter" tech-stack derived from keyword heuristics so the user gets
|
| 6 |
+
actionable output immediately, plus a clear flag that says it's a stub.
|
| 7 |
+
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import re
|
| 12 |
import uuid
|
| 13 |
from datetime import datetime, timezone
|
| 14 |
|
|
|
|
| 20 |
InferredWorkResponse,
|
| 21 |
TechStack,
|
| 22 |
TechStackPayload,
|
| 23 |
+
TechStep,
|
| 24 |
)
|
| 25 |
from app.services.openrouter import OpenRouterError, get_openrouter_client
|
| 26 |
|
| 27 |
|
| 28 |
+
# Keyword-driven stub mapping. When the live OpenRouter call fails the user
|
| 29 |
+
# still gets a meaningful, expandable baseline so the UI never dead-ends.
|
| 30 |
+
_KEYWORD_STEPS: list[tuple[re.Pattern[str], tuple[str, str]]] = [
|
| 31 |
+
(re.compile(r"водопониж", re.I),
|
| 32 |
+
("Бурение скважин под водопонижение",
|
| 33 |
+
"Бурение шурфов / водопонижающих скважин в осях, указанных заказчиком.")),
|
| 34 |
+
(re.compile(r"фундамент", re.I),
|
| 35 |
+
("Устройство фундамента",
|
| 36 |
+
"Земляные работы, устройство опалубки, армирование, бетонирование.")),
|
| 37 |
+
(re.compile(r"плит", re.I),
|
| 38 |
+
("Устройство плиты перекрытия",
|
| 39 |
+
"Монтаж опалубки, армокаркас, бетонирование с виброуплотнением, уход.")),
|
| 40 |
+
(re.compile(r"кров", re.I),
|
| 41 |
+
("Устройство кровли",
|
| 42 |
+
"Монтаж стропильной системы / пирога, гидроизоляция, контроль уклонов.")),
|
| 43 |
+
(re.compile(r"стен", re.I),
|
| 44 |
+
("Возведение стен",
|
| 45 |
+
"Кладка / монтаж элементов, утепление, контроль вертикали.")),
|
| 46 |
+
(re.compile(r"бетон", re.I),
|
| 47 |
+
("Бетонирование конструкции",
|
| 48 |
+
"Армирование, монтаж опалубки, укладка бетонной смеси, уход.")),
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _fallback_payload(payload: DailyWorkInput, reason: str) -> TechStack:
|
| 53 |
+
"""Deterministic minimal tech-stack based on keyword matching.
|
| 54 |
+
|
| 55 |
+
Ensures the response shape is contract-correct for the frontend even
|
| 56 |
+
when all OpenRouter models are rate-limited. Marked with a hint so the
|
| 57 |
+
UI can warn the user that AI enrichment is pending.
|
| 58 |
+
"""
|
| 59 |
+
steps: list[TechStep] = []
|
| 60 |
+
for pat, (title, description) in _KEYWORD_STEPS:
|
| 61 |
+
if pat.search(payload.raw_text):
|
| 62 |
+
steps.append(TechStep(
|
| 63 |
+
order=len(steps) + 1,
|
| 64 |
+
title=title,
|
| 65 |
+
description=description,
|
| 66 |
+
required_acts=["АОСР", "Общий журнал работ"],
|
| 67 |
+
weather_sensitive=True,
|
| 68 |
+
))
|
| 69 |
+
if not steps:
|
| 70 |
+
steps.append(TechStep(
|
| 71 |
+
order=1,
|
| 72 |
+
title="Базовый тех-стек по описанию пользователя",
|
| 73 |
+
description=(
|
| 74 |
+
"ИИ-модели временно недоступны; приведена минимальная структура."
|
| 75 |
+
),
|
| 76 |
+
required_acts=["Общий журнал работ"],
|
| 77 |
+
))
|
| 78 |
+
|
| 79 |
+
inferred_summary = (
|
| 80 |
+
f"Распознан базовый набор этапов из описания «{payload.raw_text}». "
|
| 81 |
+
f"AI-обогащение недоступно ({reason}); уточните параметры для детализации."
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return TechStackPayload(
|
| 85 |
+
inferred_summary=inferred_summary,
|
| 86 |
+
steps=steps,
|
| 87 |
+
required_documents=["Общий журнал работ"],
|
| 88 |
+
missing_info_prompts=[
|
| 89 |
+
"Уточните точные оси / отметки / объёмы работ.",
|
| 90 |
+
"Подтвердите температурный режим работ (если наружные).",
|
| 91 |
+
"При наличии — пришлите рабочий проект / чертёж PDF.",
|
| 92 |
+
],
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
async def infer_tech_stack(payload: DailyWorkInput) -> InferredWorkResponse:
|
| 97 |
+
"""Run inference on user's daily-work input and return structured tech-stack.
|
| 98 |
+
|
| 99 |
+
Behaviour:
|
| 100 |
+
1. Try the OpenRouter chain (5 models, hard 25-s budget).
|
| 101 |
+
2. On total failure → deterministic stub so the UI never hangs.
|
| 102 |
+
"""
|
| 103 |
system_prompt, user_prompt = build_tech_stack_prompt(
|
| 104 |
raw_text=payload.raw_text,
|
| 105 |
date_from=payload.date_from.isoformat(),
|
|
|
|
| 107 |
axes=payload.axes,
|
| 108 |
)
|
| 109 |
|
| 110 |
+
parsed: TechStackPayload | None = None
|
| 111 |
+
model_used = "fallback:keyword-stub"
|
| 112 |
try:
|
| 113 |
+
client = get_openrouter_client()
|
| 114 |
parsed, model_used = await client.structured_completion(
|
| 115 |
schema=TechStackPayload,
|
| 116 |
system_prompt=system_prompt,
|
| 117 |
user_prompt=user_prompt,
|
| 118 |
)
|
| 119 |
except OpenRouterError as exc:
|
| 120 |
+
logger.warning(
|
| 121 |
+
f"[infer] chain exhausted → switching to keyword stub ({exc})"
|
| 122 |
+
)
|
| 123 |
+
parsed = _fallback_payload(payload, reason="OpenRouter upstream rate-limited")
|
| 124 |
|
| 125 |
tech_stack = TechStack(
|
| 126 |
source_input=payload.raw_text,
|
tests/test_stub_fallback.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test that the inference service falls back to a stub when OpenRouter fails."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
from datetime import date
|
| 9 |
+
|
| 10 |
+
from app.models.tech_stack import DailyWorkInput
|
| 11 |
+
from app.services.tech_stack_service import infer_tech_stack
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def _run() -> int:
|
| 15 |
+
print("=" * 60)
|
| 16 |
+
print(" Stub fallback test (forces OpenRouter failure)")
|
| 17 |
+
print("=" * 60)
|
| 18 |
+
|
| 19 |
+
# Force-fail OpenRouter by pointing at a black hole URL.
|
| 20 |
+
from app.core.config import get_settings
|
| 21 |
+
settings = get_settings()
|
| 22 |
+
real_base = settings.openrouter_base_url
|
| 23 |
+
settings.openrouter_base_url = "http://127.0.0.1:1" # always fails
|
| 24 |
+
print(f"Temporarily set OPENROUTER_BASE_URL = {settings.openrouter_base_url}")
|
| 25 |
+
|
| 26 |
+
sample = DailyWorkInput(
|
| 27 |
+
raw_text="Залита плита перекрытия в осях А-В / 1-3",
|
| 28 |
+
date_from=date(2026, 5, 1),
|
| 29 |
+
date_to=date(2026, 5, 15),
|
| 30 |
+
axes="А-В / 1-3",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
response = await infer_tech_stack(sample)
|
| 35 |
+
finally:
|
| 36 |
+
settings.openrouter_base_url = real_base
|
| 37 |
+
|
| 38 |
+
print(f"\n[ok] stub response received")
|
| 39 |
+
print(f" request_id : {response.request_id}")
|
| 40 |
+
print(f" model_used : {response.tech_stack.model_used}")
|
| 41 |
+
print(f" inferred_summary: {response.tech_stack.inferred_summary}")
|
| 42 |
+
print(f" steps : {len(response.tech_stack.steps)}")
|
| 43 |
+
for s in response.tech_stack.steps:
|
| 44 |
+
print(f" - {s.title}")
|
| 45 |
+
|
| 46 |
+
out_path = "tests/.last_stub.json"
|
| 47 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 48 |
+
json.dump(response.model_dump(mode="json"), f, ensure_ascii=False, indent=2)
|
| 49 |
+
print(f"\nFull JSON → {out_path}")
|
| 50 |
+
|
| 51 |
+
assert "stub" in response.tech_stack.model_used, "expected stub marker"
|
| 52 |
+
assert len(response.tech_stack.steps) >= 1, "expected fallback steps"
|
| 53 |
+
print("\n✅ PASS — stub fallback returns valid response")
|
| 54 |
+
return 0
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
sys.exit(asyncio.run(_run()))
|