Полный отказ от 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
+33 -19
View File
@@ -2,20 +2,20 @@
Граница (vector_tasks.md B13-принцип 1, B14):
- build_search_model(input) -> SearchModel — чистая функция.
- Единственная внешняя зависимость — claude_analyze (async, httpx):
она уже была «внутри» расчёта в старом analyze_case; изолируется
параметром движка, чтобы тесты могли подменять её моком.
- Анализатор (нарратив/зоны) инъекцией: рантайм — services.rules_analysis
(детерминированные правила, без LLM — карточки детей не покидают контур);
тесты подменяют моком. Анализатору достаточно вернуть объект с полями
urgency/primary_zones/search_radius_km/... (structurally typed).
- Ни одной привязки к полевым транспортам/протоколам (критерий B20:
grep по этому файлу не находит соответствующих терминов).
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Awaitable, Callable
from typing import Any, Awaitable, Callable, Protocol
from pydantic import BaseModel, Field
from services.claude_service import AnalysisResult
from services.distance_service import (
calculate_max_distance,
get_base_speed,
@@ -32,8 +32,22 @@ from services.psychotype_service import (
)
from services.scoring_service import WeightedScorer
# Тип анализатора: async (case_data) -> AnalysisResult, как claude_analyze.
ClaudeAnalyzer = Callable[[dict[str, Any]], Awaitable[AnalysisResult]]
class AnalysisOutcome(Protocol):
"""Структурный контракт результата анализатора (duck-typed)."""
urgency: str
primary_zones: list[Any]
search_radius_km: float
key_locations: list[str]
behavioral_prediction: str
immediate_actions: list[str]
summary: str
fallback_used: bool
# Тип анализатора: async (case_data) -> объект с полями AnalysisOutcome.
CaseAnalyzer = Callable[[dict[str, Any]], Awaitable[AnalysisOutcome]]
# Диагнозы, которые форма собирает, но поведенческая модель не покрывает
# (vector_tasks.md B12: профили без аналога/данных — честно помечать «вне модели»).
@@ -72,7 +86,7 @@ class SearchInput(BaseModel):
time_of_day: str | None = None
def to_case_data(self) -> dict[str, Any]:
"""Плоский dict для сервисов (claude/scoring) — тот же состав полей,
"""Плоский dict для сервисов (анализатор/скоринг) — тот же состав полей,
что раньше уходил из analyze_case."""
data: dict[str, Any] = {
'age': self.age,
@@ -209,13 +223,13 @@ def derive_time_of_day(loss_time: Any) -> str:
async def build_search_model(
search_input: SearchInput,
*,
analyzer: ClaudeAnalyzer,
analyzer: CaseAnalyzer,
analyzed_at: datetime | None = None,
) -> SearchModel:
"""Чистая функция расчёта поисковой модели (B14).
Вся логика бывшего analyze_case: деривации полей, психотип,
WeightedScorer, коэффициенты формулы Экстремум, claude_analyze.
WeightedScorer, коэффициенты формулы Экстремум, analyzer (rules/мок).
DB / auth / HTTP здесь не нужны; анализатор подменяется параметром.
"""
case_data = search_input.to_case_data()
@@ -246,7 +260,7 @@ async def build_search_model(
case_data['psychotype_modifiers'] = psychotype_modifiers
max_distance_km = calculate_max_distance(case_data)
claude_result = await analyzer(case_data)
analysis = await analyzer(case_data)
scorer = WeightedScorer()
if case_data.get('age'):
@@ -282,12 +296,12 @@ async def build_search_model(
active_profiles=scorer.get_active_profiles_info(),
critical_warnings=list(scorer.critical_warnings),
unmodeled_profiles=unmodeled_profiles(case_data.get('diagnosis_type') or []),
urgency=claude_result.urgency,
primary_zones=[zone.model_dump() for zone in claude_result.primary_zones],
search_radius_km=claude_result.search_radius_km,
key_locations=claude_result.key_locations,
behavioral_prediction=claude_result.behavioral_prediction,
immediate_actions=claude_result.immediate_actions,
summary=claude_result.summary,
fallback_used=claude_result.fallback_used,
urgency=analysis.urgency,
primary_zones=[zone.model_dump() if hasattr(zone, "model_dump") else dict(zone) for zone in analysis.primary_zones],
search_radius_km=analysis.search_radius_km,
key_locations=analysis.key_locations,
behavioral_prediction=analysis.behavioral_prediction,
immediate_actions=analysis.immediate_actions,
summary=analysis.summary,
fallback_used=analysis.fallback_used,
)