feat: завершённые поиски — ручной ввод + детерминированный прогон модели (без ИИ)

Бэкенд:
- schemas.py: ClosedCaseCreate/Response — форма ~25 полей (субъект, среда,
  ресурсы, исход) для внесения завершённых поисков вручную
- routers/closed_cases.py: CRUD + POST {id}/reanalyze. _run_model() —
  тот же скоринг, что /analyze (WeightedScorer + distance_service +
  психотип), НО БЕЗ claude_service: urgency/behavior/actions по правилам
- _accuracy_note(): прогнозный радиус vs факт (в радиусе / мимо)
- main.py: роутер подключён

Фронтенд:
- pages/ClosedCases.jsx + .css: форма внесения (VCL-density, чипы диагнозов/
  рельефа), блок 'Прогноз vs факт', таблица архива (в радиусе/мимо)
- AdminDashboard.jsx: вкладки Кейсы | Завершённые поиски

Тесты: test_closed_cases.py — 6 passed. Существующие 15 failed
(test_distance_service) не связаны с изменениями — падали до них.
This commit is contained in:
2026-09-09 10:41:33 +03:00
parent d6e6277d85
commit 3e38aae2be
7 changed files with 1065 additions and 0 deletions
+63
View File
@@ -138,3 +138,66 @@ class ParseDocResponse(BaseModel):
parsed: bool
preview: dict[str, Any]
raw_text: str | None = None
# ================= ЗАВЕРШЁННЫЕ ПОИСКИ (ручной ввод, без ИИ) =================
# Оператор руками вносит данные ЗАВЕРШЁННОГО поиска: параметры субъекта/среды
# (вход матмодели) + реальный исход. Цель — датасет для калибровки модели
# (сравнение прогноза ВЕКТОРА с фактом) и архив 14 лет без LLM.
class ClosedCaseCreate(BaseModel):
# --- Субъект (вход модели) ---
age_years: int
gender: str = 'м' # 'м' | 'ж'
diagnosis_type: list[str] = Field(default_factory=list) # РАС, эпилепсия, СДВГ, ЗПР...
has_transport: str = 'none' # none|bike|scooter|other
cant_swim: bool = False
fitness_level: str | None = None # низкий|средний|высокий
# --- Обстоятельства (вход матмодели) ---
elapsed_hours: float
loss_time: datetime | None = None # время потери (для time_of_day)
terrain: list[str] = Field(default_factory=list) # лес, болото, поле...
weather: str | None = None
season: str | None = None
temperature_c: float | None = None
tnp_lat: float | None = None
tnp_lon: float | None = None
tnp_address: str | None = None
# --- Психотип (опционально, вход скоринга) ---
psychotype: str | None = None
psychotype_answers: dict[str, Any] | None = None
# --- Ресурсы ---
teams_count: int | None = None
team_size: int | None = None
has_dog: bool = False
# --- Исход (факт, для сравнения с прогнозом) ---
found_alive: bool
found_distance_km: float | None = None
found_direction: str | None = None
found_location_type: str | None = None
found_lat: float | None = None
found_lon: float | None = None
search_duration_hours: float | None = None
who_found: str | None = None
# --- Мета ---
notes: str | None = None
source: str | None = None # откуда: спецдонесение 2015, бумажный журнал...
class ClosedCaseResponse(ClosedCaseCreate):
id: str
created_at: datetime
# Сохранённый прогноз модели на момент внесения (для калибровки)
model_prediction: dict[str, Any] | None = None
# Насколько прогноз совпал с фактом
accuracy_note: str | None = None
class ClosedCaseListResponse(BaseModel):
items: list[ClosedCaseResponse]
total: int