""" Analysis API endpoints for full case analysis pipeline. Pipeline: distance → geo → scoring → psychotype → claude → merged result """ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from pydantic import BaseModel, Field from typing import List, Optional, Dict, Any from uuid import UUID from datetime import datetime import time from database import get_db from models import Case, AnalysisLog, User from api.v1.auth import get_current_user # Import services from services.distance_service import calculate_max_distance from services.geo_service import build_search_zones from services.scoring_service import WeightedScorer from services.psychotype_service import ( detect_psychotype, get_psychotype_modifiers, get_search_recommendations ) from services.claude_service import analyze_case as claude_analyze router = APIRouter() class AnalysisRequest(BaseModel): """Request for full case analysis""" case_id: UUID = Field(..., description="ID случая для анализа") class ZoneResult(BaseModel): """Search zone with score and recommendations""" priority: int name: str direction: str distance_km: float score: float reasoning: str forest_pct: Optional[float] = None road_density: Optional[float] = None water_distance_km: Optional[float] = None class AnalysisResponse(BaseModel): """Full analysis result""" case_id: UUID analyzed_at: datetime # Distance calculation max_distance_km: float # Psychotype (if available) psychotype: Optional[str] = None psychotype_modifiers: Optional[Dict[str, Any]] = None psychotype_recommendations: Optional[Dict[str, Any]] = None # Zones zones: List[ZoneResult] # Claude analysis (if available) urgency: Optional[str] = None key_locations: Optional[List[str]] = None immediate_actions: Optional[List[str]] = None behavioral_prediction: Optional[str] = None summary: Optional[str] = None # Meta execution_time_ms: float services_used: List[str] class SavedAnalysisResponse(BaseModel): """Saved analysis result from database""" case_id: UUID analysis_log: Dict[str, Any] created_at: datetime @router.post("/analyze", response_model=AnalysisResponse, status_code=200) async def analyze_full_case( request: AnalysisRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Запустить полный анализ случая. Пайплайн: 1. Distance service - расчет максимальной дистанции 2. Geo service - построение зон поиска 3. Scoring service - оценка и ранжирование зон 4. Psychotype service - определение психотипа (если есть данные) 5. Claude service - интеллектуальный анализ (опционально) 6. Merge results - объединение результатов Требуется аутентификация (operator, field, admin). """ start_time = time.time() services_used = [] # 1. Получить случай из БД case = db.query(Case).filter(Case.id == request.case_id).first() if not case: raise HTTPException(status_code=404, detail=f"Case {request.case_id} not found") # Подготовить данные для анализа case_data = { 'age': case.age_years, 'gender': case.gender, 'elapsed_hours': case.elapsed_hours or 1.0, 'terrain_primary': case.terrain[0] if case.terrain else 'лес', 'season': case.season or 'лето', 'temperature_c': case.temperature_c or 20.0, 'has_transport': case.has_transport, 'has_diagnosis': case.has_diagnosis, 'diagnosis_type': case.diagnosis_type or [], 'tnp_lat': case.tnp_lat, 'tnp_lon': case.tnp_lon, } # 2. Distance service - расчет максимальной дистанции try: max_distance = calculate_max_distance(case_data) services_used.append('distance') except Exception as e: raise HTTPException(status_code=500, detail=f"Distance calculation failed: {str(e)}") # 3. Geo service - построение зон поиска (если есть координаты) zones_data = [] if case.tnp_lat and case.tnp_lon: try: zones_data = await build_search_zones( lat=case.tnp_lat, lon=case.tnp_lon, case_data=case_data, max_distance_km=max_distance ) services_used.append('geo') except Exception as e: # Geo service опционален, продолжаем без него print(f"Geo service failed: {e}") # 4. Scoring service - оценка и ранжирование зон scored_zones = [] if zones_data: try: scorer = WeightedScorer() for zone in zones_data: zone_dict = zone.model_dump() if hasattr(zone, 'model_dump') else zone score = scorer.score_zone(zone_dict, case_data, max_distance_km=max_distance) zone_dict['score'] = score zone_dict['reasoning'] = f"Оценка на основе {len(case_data)} факторов" scored_zones.append(zone_dict) # Сортировать по score scored_zones.sort(key=lambda z: z.get('score', 0), reverse=True) services_used.append('scoring') except Exception as e: print(f"Scoring service failed: {e}") scored_zones = _create_fallback_zones(max_distance) else: # Если geo не работает, создаем базовые зоны scored_zones = _create_fallback_zones(max_distance) # 5. Psychotype service - определение психотипа psychotype = None psychotype_modifiers = None psychotype_recommendations = None if case.psychotype_answers: try: psychotype = detect_psychotype(case.psychotype_answers) psychotype_modifiers = get_psychotype_modifiers(psychotype) psychotype_recommendations = get_search_recommendations(psychotype) services_used.append('psychotype') # Применить модификаторы психотипа к зонам scored_zones = _apply_psychotype_modifiers(scored_zones, psychotype_modifiers) except Exception as e: print(f"Psychotype service failed: {e}") # 6. Claude service - интеллектуальный анализ (опционально) urgency = None key_locations = None immediate_actions = None behavioral_prediction = None summary = None try: claude_result = await claude_analyze(case_data) urgency = claude_result.urgency key_locations = claude_result.key_locations immediate_actions = claude_result.immediate_actions behavioral_prediction = claude_result.behavioral_prediction summary = claude_result.summary services_used.append('claude') except Exception as e: # Claude опционален, продолжаем без него print(f"Claude service failed: {e}") # 7. Формируем результат zones_result = [ ZoneResult( priority=i + 1, name=zone.get('name', f"Зона {zone.get('direction', 'N')}"), direction=zone.get('direction', 'N'), distance_km=zone.get('distance_km', 0), score=zone.get('score', 0), reasoning=zone.get('reasoning', 'Автоматическая оценка'), forest_pct=zone.get('forest_pct'), road_density=zone.get('road_density'), water_distance_km=zone.get('water_distance_km') ) for i, zone in enumerate(scored_zones[:10]) # Топ-10 зон ] execution_time = (time.time() - start_time) * 1000 # 8. Сохранить результат в analysis_log analysis_result = { 'max_distance_km': max_distance, 'psychotype': psychotype, 'psychotype_modifiers': psychotype_modifiers, 'zones': [z.model_dump() for z in zones_result], 'urgency': urgency, 'key_locations': key_locations, 'immediate_actions': immediate_actions, 'summary': summary, 'services_used': services_used, 'execution_time_ms': execution_time } # Обновить case.analysis_log case.analysis_log = analysis_result db.commit() # Создать запись в AnalysisLog log_entry = AnalysisLog( case_id=case.id, user_id=current_user.id, analysis_type='full_pipeline', input_data={'case_id': str(case.id)}, output_data=analysis_result, execution_time=execution_time / 1000, status='success' ) db.add(log_entry) db.commit() return AnalysisResponse( case_id=case.id, analyzed_at=datetime.utcnow(), max_distance_km=max_distance, psychotype=psychotype, psychotype_modifiers=psychotype_modifiers, psychotype_recommendations=psychotype_recommendations, zones=zones_result, urgency=urgency, key_locations=key_locations, immediate_actions=immediate_actions, behavioral_prediction=behavioral_prediction, summary=summary, execution_time_ms=execution_time, services_used=services_used ) @router.get("/analyze/{case_id}", response_model=SavedAnalysisResponse) async def get_saved_analysis( case_id: UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Получить сохранённый результат анализа. Возвращает последний analysis_log из таблицы cases. Требуется аутентификация (operator, field, admin). """ case = db.query(Case).filter(Case.id == case_id).first() if not case: raise HTTPException(status_code=404, detail=f"Case {case_id} not found") if not case.analysis_log: raise HTTPException( status_code=404, detail=f"No analysis found for case {case_id}. Run POST /analyze first." ) return SavedAnalysisResponse( case_id=case.id, analysis_log=case.analysis_log, created_at=case.created_at ) def _create_fallback_zones(max_distance: float) -> List[Dict[str, Any]]: """Create basic zones when geo/scoring services fail""" directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] zones = [] for i, direction in enumerate(directions): zones.append({ 'direction': direction, 'distance_km': max_distance * 0.8, 'score': 100 - (i * 10), 'name': f"Сектор {direction}", 'reasoning': 'Базовая оценка (сервисы недоступны)' }) return zones def _apply_psychotype_modifiers(zones: List[Dict], modifiers: Dict) -> List[Dict]: """Apply psychotype modifiers to zone scores""" if not modifiers: return zones # Применяем модификаторы зон из психотипа for zone in zones: distance = zone.get('distance_km', 0) # Определяем зону дистанции if distance < 0.5: modifier = modifiers.get('zone_0_500', 1.0) elif distance < 1.5: modifier = modifiers.get('zone_500_1500', 1.0) elif distance < 2.5: modifier = modifiers.get('zone_1500_2500', 1.0) else: modifier = modifiers.get('zone_2500plus', 1.0) # Применяем модификатор к score zone['score'] = zone.get('score', 0) * modifier zone['reasoning'] += f" (психотип: ×{modifier:.1f})" # Пересортировать по score zones.sort(key=lambda z: z.get('score', 0), reverse=True) return zones