B2: tolerant JSON extraction from Claude responses + graceful fallback

_extract_json_payload handles a json/JSON/bare fence, raw JSON and JSON
embedded in prose; any unparseable response or contract violation now
degrades to the deterministic scoring_service instead of raising.
Also guards the response envelope itself (content[0].text).

B3: single home for recommendation scoring

services/recommendation_service.py holds the rules; routers/stats.py and
backend/services/stats_service.py both delegate to it. Unified rules are the
union of the two old copies: same weights/threshold, substring matching
(superset of the old exact match), tolerant key aliases, health_flags rule
kept. Endpoint response contract unchanged.

Plus: Overpass circuit breaker and concurrent zone queries in geo_service -
128 sequential calls per analysis no longer each burn a connect timeout when
the host has no outbound network.

Tests: 152 -> 194 passed.
This commit is contained in:
root
2026-07-25 13:01:02 +00:00
parent 76278f5fe1
commit 8b3a2cbf7e
7 changed files with 509 additions and 59 deletions
+47 -12
View File
@@ -171,21 +171,56 @@ async def analyze_with_claude(case_data: dict, api_key: str) -> AnalysisResult:
if response.status_code != 200:
raise Exception(f"Anthropic API error: {response.status_code} - {response.text}")
result = response.json()
content = result["content"][0]["text"]
try:
result = response.json()
content = result["content"][0]["text"]
analysis_data = _extract_json_payload(content)
analysis_data['fallback_used'] = False
return AnalysisResult(**analysis_data)
except Exception as e:
# Ответ модели пришёл в неожидаемом виде — не роняем анализ,
# а отдаём детерминированный результат scoring_service.
logger.warning(
f"Не удалось разобрать ответ Claude ({type(e).__name__}: {e}). "
"Используется fallback scoring service."
)
return await analyze_with_fallback(case_data)
# Парсим JSON из ответа
# Убираем возможные markdown блоки кода
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
content = content.split("```")[1].split("```")[0].strip()
analysis_data = json.loads(content)
analysis_data['fallback_used'] = False
def _extract_json_payload(content: str) -> dict:
"""Extract a JSON object from a model response.
# Преобразуем в Pydantic модель
return AnalysisResult(**analysis_data)
Tolerates a ```json fence, a bare ``` fence, or raw JSON with
surrounding prose. Raises ValueError if nothing parseable is found.
"""
candidates = []
stripped = (content or '').strip()
if '```' in stripped:
for marker in ('```json', '```JSON', '```'):
if marker in stripped:
after = stripped.split(marker, 1)[1]
candidates.append(after.split('```', 1)[0].strip())
break
candidates.append(stripped)
# Last resort: the widest {...} span in the text.
start, end = stripped.find('{'), stripped.rfind('}')
if start != -1 and end > start:
candidates.append(stripped[start:end + 1])
for candidate in candidates:
if not candidate:
continue
try:
parsed = json.loads(candidate)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(parsed, dict):
return parsed
raise ValueError('Не удалось извлечь JSON из ответа модели')
async def analyze_with_fallback(case_data: dict) -> AnalysisResult: