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:
+47
-12
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user