268 lines
8.7 KiB
Python
268 lines
8.7 KiB
Python
"""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))
|