"""Storage for Дирижёр interactions — self-learning and analytics.""" import json from collections import Counter from datetime import datetime, timedelta, timezone from typing import Any from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.models.conductor import ConductorInteraction from app.models.pipeline import PipelineStats from app.models.user import User from app.models.voice_command import VoiceCommand async def log_interaction( db: AsyncSession, user_id: str | None, input_text: str, detected_intent: str, selected_agent: str, response_text: str, processing_time_ms: float, confidence: int = 80, verification_status: str = "verified", was_auto_routed: bool = True, context: dict[str, Any] | None = None, session_id: str | None = None, ) -> str: log = ConductorInteraction( user_id=user_id, session_id=session_id, input_text=input_text, detected_intent=detected_intent, selected_agent=selected_agent, response_text=response_text, processing_time_ms=processing_time_ms, confidence_score=confidence, verification_status=verification_status, was_auto_routed=was_auto_routed, context=json.dumps(context) if context else None, ) db.add(log) await db.commit() await db.refresh(log) return str(log.id) async def rate_interaction(db: AsyncSession, interaction_id: str, rating: int) -> bool: result = await db.execute( update(ConductorInteraction) .where(ConductorInteraction.id == interaction_id) .values(user_rating=rating) ) await db.commit() return result.rowcount > 0 async def get_similar_successful( db: AsyncSession, input_text: str, limit: int = 5, hours: int = 24 * 7, ) -> list[dict[str, Any]]: cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) result = await db.execute( select(ConductorInteraction) .where(ConductorInteraction.created_at >= cutoff) .where(ConductorInteraction.user_rating >= 4) .where(ConductorInteraction.confidence_score >= 70) .order_by(ConductorInteraction.created_at.desc()) .limit(limit * 3) ) logs = result.scalars().all() scored = [] for log in logs: score = _text_similarity(input_text.lower(), log.input_text.lower()) if score > 0.3: scored.append((score, { "input": log.input_text, "agent": log.selected_agent, "response": log.response_text, "rating": log.user_rating, })) scored.sort(key=lambda x: -x[0]) return [s[1] for s in scored[:limit]] async def get_session_history( db: AsyncSession, session_id: str, limit: int = 100, ) -> list[dict[str, Any]]: result = await db.execute( select(ConductorInteraction) .where(ConductorInteraction.session_id == session_id) .order_by(ConductorInteraction.created_at.asc()) .limit(limit) ) return [ { "id": str(log.id), "input": log.input_text, "agent": log.selected_agent, "response": log.response_text, "confidence": log.confidence_score, "rating": log.user_rating, "created_at": log.created_at.isoformat(), } for log in result.scalars().all() ] async def get_recent_history( db: AsyncSession, user_id: str, limit: int = 10, ) -> list[dict[str, Any]]: result = await db.execute( select(ConductorInteraction) .where(ConductorInteraction.user_id == user_id) .order_by(ConductorInteraction.created_at.desc()) .limit(limit) ) return [ { "input": log.input_text, "agent": log.selected_agent, "response": log.response_text, "confidence": log.confidence_score, "rating": log.user_rating, "created_at": log.created_at.isoformat(), } for log in result.scalars().all() ] async def check_suggested_command( db: AsyncSession, user_id: str, min_count: int = 3, ) -> str | None: """Check if user has a command that's been used enough to suggest customizing it.""" result = await db.execute( select(VoiceCommand) .where(VoiceCommand.user_id == user_id) .where(VoiceCommand.count >= min_count) .order_by(VoiceCommand.count.desc()) .limit(1) ) cmd = result.scalar_one_or_none() if not cmd: return None return f"Команда «{cmd.phrase}» сработала {cmd.count} раз. Настроить в /voice/help" AUTO_TUNING_CONFIG = { "min_samples": 5, "rejection_threshold": 5, "lookback_hours": 24, "adjustment_factor": 0.05, } async def auto_tune_user( db: AsyncSession, user_id: str, config: dict[str, Any] | None = None, ) -> dict[str, Any]: """Analyze user interaction patterns and auto-tune pipeline parameters. Examines recent rejections, low-rated interactions, and pipeline failures, then adjusts User.pipeline_tuning JSONB accordingly. """ tuning = config or dict(AUTO_TUNING_CONFIG) min_samples = tuning.get("min_samples", 5) lookback = tuning.get("lookback_hours", 24) cutoff = datetime.now(timezone.utc) - timedelta(hours=lookback) # ── 1. Count explicit rejections (rating < 3) ── explicit_result = await db.execute( select(func.count(ConductorInteraction.id)) .where(ConductorInteraction.user_id == user_id) .where(ConductorInteraction.created_at >= cutoff) .where(ConductorInteraction.user_rating < 3) ) explicit_rejections = explicit_result.scalar() or 0 # ── 2. Count implicit rejections (confidence < 50, needs_clarification) ── implicit_result = await db.execute( select(func.count(ConductorInteraction.id)) .where(ConductorInteraction.user_id == user_id) .where(ConductorInteraction.created_at >= cutoff) .where(ConductorInteraction.verification_status == "needs_clarification") ) implicit_rejections = implicit_result.scalar() or 0 total_rejections = explicit_rejections + implicit_rejections # ── 3. Pipeline stage failures ── stage_fails = await db.execute( select(PipelineStats.stage, func.count(PipelineStats.id)) .where(PipelineStats.user_id == user_id) .where(PipelineStats.created_at >= cutoff) .where(PipelineStats.passed == False) .group_by(PipelineStats.stage) .order_by(func.count(PipelineStats.id).desc()) ) stage_failures: dict[str, int] = dict(stage_fails.all()) # ── 4. Calculate adjustments ── adjustments: dict[str, Any] = {} rejection_ratio = total_rejections / max(min_samples, 1) if total_rejections >= tuning.get("rejection_threshold", 5): adj = tuning.get("adjustment_factor", 0.05) adjustments["confidence_boost"] = round(min(adj * rejection_ratio, 0.3), 2) adjustments["needs_clarification"] = True if "vad" in stage_failures and stage_failures["vad"] >= 3: adjustments["vad_noise_threshold"] = 0.4 adjustments["vad_silence_timeout_ms"] = 2000 if "wake_word" in stage_failures and stage_failures["wake_word"] >= 3: adjustments["wake_word_sensitivity"] = 0.8 if "semantic_validation" in stage_failures and stage_failures["semantic_validation"] >= 3: adjustments["semantic_validation_timeout_ms"] = 8000 # ── 5. Store in User.pipeline_tuning ── result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user and adjustments: current_tuning = user.pipeline_tuning or {} current_tuning["auto_tuned_at"] = datetime.now(timezone.utc).isoformat() current_tuning["adjustments"] = { **current_tuning.get("adjustments", {}), **adjustments, } current_tuning["stats"] = { "explicit_rejections": explicit_rejections, "implicit_rejections": implicit_rejections, "total_rejections": total_rejections, "stage_failures": stage_failures, } user.pipeline_tuning = current_tuning await db.commit() return { "tuned": bool(adjustments), "adjustments": adjustments, "total_rejections": total_rejections, "stage_failures": stage_failures, } def _text_similarity(a: str, b: str) -> float: if not a or not b: return 0.0 words_a = set(a.split()) words_b = set(b.split()) if not words_a or not words_b: return 0.0 intersection = words_a & words_b return len(intersection) / max(len(words_a), len(words_b))