Files
voidea/app/agents/conductor_storage.py
T

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))