Dirbol commited on
Commit
46c3b99
·
1 Parent(s): 0c46399

deploy: backend 2026-06-22T08:22:30Z

Browse files
.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=meta-llama/llama-3.3-70b-instruct:free
11
  # Comma-separated chain of fallback free models. Tried in order if primary fails.
12
- OPENROUTER_FREE_MODEL_FALLBACKS=qwen/qwen3-next-80b-a3b-instruct:free,nvidia/nemotron-3-super-120b-a12b:free,openai/gpt-oss-20b:free,google/gemma-4-31b-it:free
 
 
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="meta-llama/llama-3.3-70b-instruct:free"
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 downtime. This client:
4
- - Sets the three required headers (Authorization, HTTP-Referer, X-Title).
5
- - Tries primary free model first; on validation/transport failure → fallback model.
6
- - Forces JSON output via `response_format` and validates with a Pydantic schema.
 
 
 
 
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 all retries and fallbacks."""
31
 
32
 
33
  class _StructuredResponseError(Exception):
34
- """Internal: model returned JSON that did not match the requested schema."""
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=httpx.Timeout(120.0, connect=15.0),
59
  )
60
  return self._client
61
 
@@ -64,22 +75,14 @@ class OpenRouterClient:
64
  await self._client.aclose()
65
  self._client = None
66
 
67
- @retry(
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
- """Call a specific OpenRouter model and return raw assistant text."""
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.debug(f"[openrouter] POST {model}")
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"Unexpected OpenRouter response shape: {exc}"
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: primary model → chain of fallback models. Each model gets up
116
- to 2 attempts (2nd attempt re-emphasises JSON-only output).
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
- tried: set[str] = set()
132
- for model_name in chain:
133
- if model_name in tried:
134
- continue
135
- tried.add(model_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  for attempt in range(1, 3):
 
 
 
 
 
 
 
137
  try:
138
- raw = await self._call_model(
139
- model=model_name,
140
- system_prompt=system_prompt,
141
- user_prompt=user_prompt,
 
 
 
142
  )
143
- return schema.model_validate_json(raw), model_name
 
 
 
 
 
 
 
 
 
 
 
144
  except _StructuredResponseError as exc:
 
145
  logger.warning(
146
- f"[openrouter] {model_name} attempt {attempt} schema fail: {exc}"
147
  )
148
- user_prompt = (
149
- user_prompt
150
- + "\n\nReturn ONLY valid JSON with no commentary or markdown."
151
  )
152
- continue
 
 
153
  except (httpx.HTTPStatusError, ValidationError) as exc:
 
154
  logger.warning(
155
- f"[openrouter] {model_name} attempt {attempt} error: {exc}"
 
156
  )
157
  break # move to next model
158
  except httpx.HTTPError as exc:
 
159
  logger.warning(
160
- f"[openrouter] {model_name} attempt {attempt} transport error: {exc}"
 
161
  )
162
- break # move to next model
163
 
164
  raise OpenRouterError(
165
- f"All OpenRouter free-model fallbacks exhausted. "
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
- client = get_openrouter_client()
 
 
 
 
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
- # Validate against the LLM-facing payload schema (no envelope fields).
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.error(f"tech-stack inference failed: {exc}")
39
- raise
 
 
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()))