Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user