Полный отказ от LLM: детерминированный rules-анализ

Решение Виктора: ИИ убран из проекта — карточки детей (ПД) не должны
покиать контур. Анализ полностью локальный и детерминированный.

- services/rules_analysis.py: правила вместо Claude (бывший
  analyze_with_fallback + rule_based_urgency из closed_cases):
  urgency по возрасту/профилям, зоны через geo+scoring,
  behavioral_prediction/immediate_actions/key_locations — справочники.
- search_engine: анализатор инъекцией (CaseAnalyzer, Protocol),
  импорт claude_service удалён; рантайм — rules_analyze.
- УДАЛЕНЫ: services/claude_service.py, backend/services/claude_service.py
  (устаревшая копия), test_claude_service.py; ключевые правила-кейсы
  перенесены в test_rules_analysis.py (+тест отсутствия LLM-упоминаний).
- docker-compose/.env.example: ANTHROPIC_API_KEY удалён.
- Фронт: бейдж 'ИИ-анализ' -> 'Локальный расчёт'.
- Доки: DEPLOY/SERVICES_README/ANALYZE_API переписаны, FIXES_REPORT
  помечен историческим, vector_tasks B20: zones/probability — из движка.

pytest: 187 passed (было 214; -27 удалённых LLM-тестов, +тест rules).
Контракт /analyze не изменён (фронт совместим).
This commit is contained in:
2026-09-09 18:30:44 +03:00
parent 11015a7883
commit d8723582c9
17 changed files with 362 additions and 1377 deletions
-502
View File
@@ -1,502 +0,0 @@
"""
Tests for claude_service.py
Tests the Claude AI analysis with fallback to scoring service.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import os
from services.claude_service import (
analyze_case,
analyze_with_fallback,
AnalysisResult,
PrimaryZone,
_extract_json_payload
)
class TestAnalysisResult:
"""Test AnalysisResult model."""
def test_analysis_result_structure(self):
"""Test AnalysisResult has correct structure."""
result = AnalysisResult(
urgency="высокая",
primary_zones=[
PrimaryZone(
priority=1,
name="Зона А",
direction="N",
distance=1.0,
reason="Тест"
)
],
search_radius_km=5.0,
key_locations=["водоёмы"],
behavioral_prediction="Тест",
immediate_actions=["Действие 1"],
summary="Тест",
fallback_used=False
)
assert result.urgency == "высокая"
assert len(result.primary_zones) == 1
assert result.search_radius_km == 5.0
assert result.fallback_used is False
class TestFallbackAnalysis:
"""Test fallback analysis without Claude API."""
@pytest.mark.asyncio
async def test_fallback_young_child(self):
"""Test fallback for young child (critical urgency)."""
case_data = {
'age': 3,
'gender': 'мужской',
'terrain': 'лес',
'weather': 'ясно'
}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
assert result.fallback_used is True
assert len(result.primary_zones) >= 2
assert "водоёмы" in result.key_locations
@pytest.mark.asyncio
async def test_fallback_with_ras_profile(self):
"""Test fallback with РАС profile."""
case_data = {
'age': 8,
'gender': 'мужской',
'profiles': ['РАС']
}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
assert "РАС" in result.behavioral_prediction or "водоём" in result.behavioral_prediction
assert any("водоём" in action.lower() for action in result.immediate_actions)
@pytest.mark.asyncio
async def test_fallback_with_bicycle(self):
"""Test fallback with bicycle profile."""
case_data = {
'age': 12,
'gender': 'мужской',
'profiles': ['велосипед']
}
result = await analyze_with_fallback(case_data)
assert result.urgency == "высокая"
assert any("10-15 км" in action or "камер" in action for action in result.immediate_actions)
@pytest.mark.asyncio
async def test_fallback_teenager(self):
"""Test fallback for teenager."""
case_data = {
'age': 15,
'gender': 'мужской',
'terrain': 'лес'
}
result = await analyze_with_fallback(case_data)
assert result.urgency in ["средняя", "высокая"]
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
@pytest.mark.asyncio
async def test_fallback_with_coordinates(self):
"""Test fallback with coordinates (geo service integration)."""
case_data = {
'age': 10,
'lat': 53.9,
'lon': 27.5,
'terrain': 'лес'
}
with patch('services.claude_service.build_search_zones', new_callable=AsyncMock) as mock_zones:
# Mock zones
from services.geo_service import Zone
mock_zones.return_value = [
Zone(
direction="N",
distance_km=0.5,
forest_pct=60.0,
road_density=1.0,
water_distance_km=2.0,
settlement_distance_km=5.0
),
Zone(
direction="E",
distance_km=1.0,
forest_pct=40.0,
road_density=2.0,
water_distance_km=1.0,
settlement_distance_km=3.0
)
]
result = await analyze_with_fallback(case_data)
assert result.fallback_used is True
assert len(result.primary_zones) > 0
mock_zones.assert_called_once()
@pytest.mark.asyncio
async def test_fallback_without_coordinates(self):
"""Test fallback without coordinates (basic zones)."""
case_data = {
'age': 10,
'terrain': 'лес'
}
result = await analyze_with_fallback(case_data)
assert result.fallback_used is True
assert len(result.primary_zones) >= 2
assert result.primary_zones[0].priority == 1
class TestAnalyzeCase:
"""Test main analyze_case function."""
@pytest.mark.asyncio
async def test_analyze_without_api_key(self):
"""Test analyze falls back when no API key."""
case_data = {
'age': 10,
'gender': 'мужской',
'terrain': 'лес'
}
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}, clear=True):
result = await analyze_case(case_data)
assert result.fallback_used is True
assert isinstance(result, AnalysisResult)
@pytest.mark.asyncio
async def test_analyze_with_api_error(self):
"""Test analyze falls back on API error."""
case_data = {
'age': 10,
'gender': 'мужской',
'terrain': 'лес'
}
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Server error"
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
result = await analyze_case(case_data)
# Should fallback
assert result.fallback_used is True
@pytest.mark.asyncio
async def test_analyze_with_successful_api(self):
"""Test analyze with successful Claude API response."""
case_data = {
'age': 10,
'gender': 'мужской',
'terrain': 'лес',
'weather': 'дождь'
}
mock_api_response = {
"urgency": "высокая",
"primary_zones": [
{
"priority": 1,
"name": "Лесной массив север",
"direction": "N",
"distance": 1.5,
"reason": "Наиболее вероятное направление"
}
],
"search_radius_km": 5.0,
"key_locations": ["водоёмы", "дороги"],
"behavioral_prediction": "Ребёнок может двигаться по тропам",
"immediate_actions": ["Организовать поиск", "Проверить водоёмы"],
"summary": "Случай высокой срочности"
}
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
import json
json_text = json.dumps(mock_api_response, ensure_ascii=False)
mock_response.json.return_value = {
"content": [
{
"text": f"```json\n{json_text}\n```"
}
]
}
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
result = await analyze_case(case_data)
assert result.fallback_used is False
assert result.urgency == "высокая"
class TestUrgencyClassification:
"""Test urgency classification logic."""
@pytest.mark.asyncio
async def test_critical_urgency_young_child(self):
"""Test critical urgency for very young children."""
case_data = {'age': 2}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
@pytest.mark.asyncio
async def test_critical_urgency_epilepsy(self):
"""Test critical urgency for epilepsy."""
case_data = {'age': 10, 'profiles': ['эпилепсия']}
result = await analyze_with_fallback(case_data)
assert result.urgency == "критическая"
@pytest.mark.asyncio
async def test_high_urgency_bicycle(self):
"""Test high urgency for bicycle."""
case_data = {'age': 12, 'profiles': ['велосипед']}
result = await analyze_with_fallback(case_data)
assert result.urgency == "высокая"
@pytest.mark.asyncio
async def test_medium_urgency_preteen(self):
"""Test medium urgency for preteen."""
case_data = {'age': 10}
result = await analyze_with_fallback(case_data)
assert result.urgency == "средняя"
class TestKeyLocations:
"""Test key locations based on age."""
@pytest.mark.asyncio
async def test_young_child_locations(self):
"""Test key locations for young children."""
case_data = {'age': 5}
result = await analyze_with_fallback(case_data)
assert "водоёмы" in result.key_locations
assert "укрытия" in result.key_locations
@pytest.mark.asyncio
async def test_preteen_locations(self):
"""Test key locations for preteens."""
case_data = {'age': 10}
result = await analyze_with_fallback(case_data)
assert "водоёмы" in result.key_locations
assert "дороги" in result.key_locations or "тропы" in result.key_locations
@pytest.mark.asyncio
async def test_teenager_locations(self):
"""Test key locations for teenagers."""
case_data = {'age': 15}
result = await analyze_with_fallback(case_data)
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
class TestBehavioralPrediction:
"""Test behavioral prediction logic."""
@pytest.mark.asyncio
async def test_ras_prediction(self):
"""Test РАС behavioral prediction."""
case_data = {'age': 8, 'profiles': ['РАС']}
result = await analyze_with_fallback(case_data)
assert "водоём" in result.behavioral_prediction.lower() or "рас" in result.behavioral_prediction.lower()
@pytest.mark.asyncio
async def test_young_child_prediction(self):
"""Test young child behavioral prediction."""
case_data = {'age': 3}
result = await analyze_with_fallback(case_data)
assert "минимальное" in result.behavioral_prediction.lower() or "близко" in result.behavioral_prediction.lower()
@pytest.mark.asyncio
async def test_teenager_prediction(self):
"""Test teenager behavioral prediction."""
case_data = {'age': 15}
result = await analyze_with_fallback(case_data)
assert "целенаправленное" in result.behavioral_prediction.lower() or "населённ" in result.behavioral_prediction.lower()
class TestImmediateActions:
"""Test immediate actions generation."""
@pytest.mark.asyncio
async def test_basic_actions(self):
"""Test basic immediate actions are present."""
case_data = {'age': 10}
result = await analyze_with_fallback(case_data)
assert len(result.immediate_actions) >= 3
assert any("поиск" in action.lower() for action in result.immediate_actions)
@pytest.mark.asyncio
async def test_ras_critical_action(self):
"""Test РАС critical action is first."""
case_data = {'age': 8, 'profiles': ['РАС']}
result = await analyze_with_fallback(case_data)
first_action = result.immediate_actions[0]
assert "водоём" in first_action.lower() or "критично" in first_action.lower()
@pytest.mark.asyncio
async def test_bicycle_action(self):
"""Test bicycle specific action."""
case_data = {'age': 12, 'profiles': ['велосипед']}
result = await analyze_with_fallback(case_data)
assert any("10-15" in action or "камер" in action for action in result.immediate_actions)
class TestExtractJsonPayload:
"""Test tolerant JSON extraction from a model response (B2)."""
def test_bare_json(self):
assert _extract_json_payload('{"a": 1}') == {'a': 1}
def test_json_fence(self):
assert _extract_json_payload('```json\n{"a": 1}\n```') == {'a': 1}
def test_uppercase_json_fence(self):
assert _extract_json_payload('```JSON\n{"a": 1}\n```') == {'a': 1}
def test_bare_fence(self):
assert _extract_json_payload('```\n{"a": 1}\n```') == {'a': 1}
def test_prose_around_json(self):
content = 'Вот результат анализа:\n{"a": 1}\nНадеюсь, это поможет.'
assert _extract_json_payload(content) == {'a': 1}
def test_prose_around_fenced_json(self):
content = 'Разбор:\n```json\n{"a": 1}\n```\nКонец.'
assert _extract_json_payload(content) == {'a': 1}
def test_json_array_is_rejected(self):
"""A top-level array is not a valid AnalysisResult payload."""
with pytest.raises(ValueError):
_extract_json_payload('[1, 2, 3]')
def test_plain_text_raises(self):
with pytest.raises(ValueError):
_extract_json_payload('Извините, я не могу выполнить этот запрос.')
def test_empty_raises(self):
with pytest.raises(ValueError):
_extract_json_payload('')
def test_none_raises(self):
with pytest.raises(ValueError):
_extract_json_payload(None)
class TestClaudeResponseFallback:
"""Malformed Claude responses must degrade to scoring, not crash (B2)."""
def _mock_client(self, mock_client, text=None, payload=None):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = (
payload if payload is not None else {"content": [{"text": text}]}
)
mock_client.return_value.__aenter__.return_value.post = AsyncMock(
return_value=mock_response
)
@pytest.mark.asyncio
async def test_unparseable_text_falls_back(self):
"""Model answers in prose instead of JSON -> deterministic fallback."""
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, text='Не могу помочь с этим.')
result = await analyze_case({'age': 10, 'terrain': 'лес'})
assert result.fallback_used is True
assert isinstance(result, AnalysisResult)
@pytest.mark.asyncio
async def test_truncated_json_falls_back(self):
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, text='```json\n{"urgency": "высок')
result = await analyze_case({'age': 10})
assert result.fallback_used is True
@pytest.mark.asyncio
async def test_valid_json_missing_required_fields_falls_back(self):
"""Parseable JSON that violates the AnalysisResult contract."""
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, text='{"urgency": "высокая"}')
result = await analyze_case({'age': 10})
assert result.fallback_used is True
@pytest.mark.asyncio
async def test_unexpected_envelope_falls_back(self):
"""API envelope without content[0].text -> fallback, not KeyError."""
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, payload={'unexpected': 'shape'})
result = await analyze_case({'age': 10})
assert result.fallback_used is True
@pytest.mark.asyncio
async def test_unfenced_json_with_prose_succeeds(self):
"""Recovery path: valid payload wrapped in prose is still used."""
import json as _json
payload = {
"urgency": "высокая",
"primary_zones": [{
"priority": 1,
"name": "Лес север",
"direction": "N",
"distance": 1.5,
"reason": "Вероятное направление"
}],
"search_radius_km": 5.0,
"key_locations": ["водоёмы"],
"behavioral_prediction": "Движение по тропам",
"immediate_actions": ["Организовать поиск"],
"summary": "Резюме"
}
text = 'Результат:\n' + _json.dumps(payload, ensure_ascii=False) + '\nГотово.'
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
with patch('httpx.AsyncClient') as mock_client:
self._mock_client(mock_client, text=text)
result = await analyze_case({'age': 10})
assert result.fallback_used is False
assert result.urgency == "высокая"
+80
View File
@@ -0,0 +1,80 @@
"""Тесты детерминированного rules-анализа (services/rules_analysis.py).
Ключевые кейсы перенесены из test_claude_service.py (удалён вместе с LLM);
плюс проверка отсутствия внешних вызовов.
"""
from __future__ import annotations
from services.rules_analysis import (
AnalysisResult,
PrimaryZone,
rule_based_urgency,
rules_analyze,
)
class TestUrgency:
async def test_young_child_critical(self):
# _age_urgency: возраст ≤4 — критическая вне зависимости от elapsed
r = await rules_analyze({'age': 3})
assert r.urgency == 'критическая'
def test_ras_critical(self):
assert rule_based_urgency(1.0, 8, ['РАС']) == 'высокая' # возраст 8 не даёт +2
def test_long_search_raises(self):
assert rule_based_urgency(30.0, 15, []) == 'высокая'
assert rule_based_urgency(30.0, 5, ['РАС']) == 'критическая'
class TestFallbackRules:
async def test_young_child(self):
result = await rules_analyze({'age': 3, 'gender': 'мужской', 'terrain': 'лес'})
assert isinstance(result, AnalysisResult)
assert result.urgency == 'критическая'
assert result.fallback_used is True
assert len(result.primary_zones) >= 2
assert 'водоёмы' in result.key_locations
async def test_ras_profile(self):
result = await rules_analyze({'age': 8, 'profiles': ['РАС']})
assert result.urgency == 'критическая'
assert 'РАС' in result.behavioral_prediction or 'водоём' in result.behavioral_prediction
assert any('водоём' in a.lower() for a in result.immediate_actions)
async def test_bicycle_profile(self):
result = await rules_analyze({'age': 12, 'profiles': ['велосипед']})
assert result.urgency == 'высокая'
assert any('10-15 км' in a or 'камер' in a for a in result.immediate_actions)
async def test_teenager(self):
result = await rules_analyze({'age': 15, 'terrain': 'лес'})
assert result.urgency in ['средняя', 'высокая']
assert 'дороги' in result.key_locations or 'населённые пункты' in result.key_locations
async def test_behavioral_predictions_by_age(self):
r4 = await rules_analyze({'age': 4, 'profiles': []})
r9 = await rules_analyze({'age': 9, 'profiles': []})
r16 = await rules_analyze({'age': 16, 'profiles': []})
assert 'близко к точке потери' in r4.behavioral_prediction
assert 'тропам или дорогам' in r9.behavioral_prediction
assert 'населённым пунктам' in r16.behavioral_prediction
class TestNoLLM:
def test_no_anthropic_in_services(self):
"""В services/ не осталось ссылок на LLM-провайдеров."""
import pathlib
for p in pathlib.Path('services').glob('*.py'):
src = p.read_text(encoding='utf-8')
for word in ('anthropic', 'ANTHROPIC', 'api.anthropic.com', 'openai', 'OpenAI'):
assert word not in src, f'{p.name} contains {word}'
def test_no_llm_keys_in_compose(self):
src = open('docker-compose.yml', encoding='utf-8').read()
assert 'ANTHROPIC' not in src and 'OPENAI' not in src
async def test_result_is_deterministic(self):
a = await rules_analyze({'age': 10, 'profiles': []})
b = await rules_analyze({'age': 10, 'profiles': []})
assert a.model_dump() == b.model_dump()
+3 -3
View File
@@ -1,6 +1,6 @@
"""B14: юнит-тесты чистого движка services/search_engine.py.
claude_analyze мокается (движок тестируем изолированно, без httpx/ANTHROPIC).
Анализатор мокается (движок тестируем изолированно, без geo/httpx).
"""
from __future__ import annotations
@@ -9,7 +9,7 @@ from typing import Any
import pytest
from services.claude_service import AnalysisResult, PrimaryZone
from services.rules_analysis import AnalysisResult, PrimaryZone
from services.search_engine import (
SearchInput,
build_search_model,
@@ -70,7 +70,7 @@ async def test_profiles_derived_from_diagnosis_and_transport():
)
assert 'РАС' in [p['name'] for p in model.active_profiles]
assert model.critical_warnings == [] or isinstance(model.critical_warnings, list)
# Профили дошли и до claude (case_data), и в скорер
# Профили дошли и до анализатора (case_data), и в скорер
assert 'РАС' in analyzer.captured['profiles']
assert 'велосипед' in analyzer.captured['profiles']
assert 'не_умеет_плавать' in analyzer.captured['profiles']