Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""Pipeline configuration and statistics service for VoIdea."""
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import AgentConfig
|
||||
from app.models.pipeline import PipelineStats
|
||||
from app.schemas.pipeline import PipelineConfigResponse, PipelineStageConfig
|
||||
|
||||
DEFAULT_PIPELINE_CONFIG: dict[str, Any] = {
|
||||
"vad": {
|
||||
"enabled": True,
|
||||
"noise_threshold": 0.3,
|
||||
"silence_timeout_ms": 1500,
|
||||
"min_audio_duration_ms": 300,
|
||||
},
|
||||
"wake_word": {
|
||||
"enabled": True,
|
||||
"word": "ВоИдея",
|
||||
"timeout_minutes": 5,
|
||||
"sensitivity": 0.7,
|
||||
},
|
||||
"semantic_validation": {
|
||||
"mode": "fast",
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
"confidence": {
|
||||
"verified_threshold": 80,
|
||||
"warning_threshold": 50,
|
||||
},
|
||||
"agent_chaining": {
|
||||
"max_agents_per_dialog": 3,
|
||||
"enabled": True,
|
||||
},
|
||||
"auto_tuning": {
|
||||
"enabled": True,
|
||||
"min_samples": 5,
|
||||
"learning_rate": 0.1,
|
||||
},
|
||||
"stages_order": [
|
||||
"vad",
|
||||
"wake_word",
|
||||
"semantic_validation",
|
||||
"routing",
|
||||
"verification",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _dict_to_response(data: dict[str, Any]) -> PipelineConfigResponse:
|
||||
stages = data.get("stages_order", DEFAULT_PIPELINE_CONFIG["stages_order"])
|
||||
config_data = {k: v for k, v in data.items() if k != "stages_order"}
|
||||
|
||||
def stage_or_default(name: str) -> PipelineStageConfig:
|
||||
default = DEFAULT_PIPELINE_CONFIG.get(name, {})
|
||||
override = config_data.get(name, {})
|
||||
merged = {**default, **override}
|
||||
return PipelineStageConfig(**merged)
|
||||
|
||||
return PipelineConfigResponse(
|
||||
vad=stage_or_default("vad"),
|
||||
wake_word=stage_or_default("wake_word"),
|
||||
semantic_validation=stage_or_default("semantic_validation"),
|
||||
confidence=stage_or_default("confidence"),
|
||||
agent_chaining=stage_or_default("agent_chaining"),
|
||||
auto_tuning=stage_or_default("auto_tuning"),
|
||||
stages_order=stages,
|
||||
)
|
||||
|
||||
|
||||
class PipelineService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_config(self) -> PipelineConfigResponse:
|
||||
result = await self.db.execute(
|
||||
select(AgentConfig).where(AgentConfig.agent_name == "conductor")
|
||||
)
|
||||
conductor = result.scalar_one_or_none()
|
||||
if not conductor or not conductor.config:
|
||||
return _dict_to_response(dict(DEFAULT_PIPELINE_CONFIG))
|
||||
|
||||
try:
|
||||
data = json.loads(conductor.config)
|
||||
if not isinstance(data, dict):
|
||||
return _dict_to_response(dict(DEFAULT_PIPELINE_CONFIG))
|
||||
return _dict_to_response(data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return _dict_to_response(dict(DEFAULT_PIPELINE_CONFIG))
|
||||
|
||||
async def update_config(self, updates: dict[str, Any]) -> PipelineConfigResponse:
|
||||
result = await self.db.execute(
|
||||
select(AgentConfig).where(AgentConfig.agent_name == "conductor")
|
||||
)
|
||||
conductor = result.scalar_one_or_none()
|
||||
if not conductor:
|
||||
conductor = AgentConfig(
|
||||
agent_name="conductor",
|
||||
description="Дирижёр — главный оркестратор",
|
||||
is_enabled=True,
|
||||
version="1.0.0",
|
||||
config=json.dumps(DEFAULT_PIPELINE_CONFIG, ensure_ascii=False),
|
||||
)
|
||||
self.db.add(conductor)
|
||||
|
||||
current = {}
|
||||
if conductor.config:
|
||||
try:
|
||||
current = json.loads(conductor.config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
current = {}
|
||||
|
||||
merged = {**DEFAULT_PIPELINE_CONFIG, **current}
|
||||
|
||||
stages_order = updates.pop("stages_order", None)
|
||||
if stages_order is not None:
|
||||
merged["stages_order"] = stages_order
|
||||
|
||||
for key, value in updates.items():
|
||||
if value is not None and isinstance(value, dict):
|
||||
existing = merged.get(key, {})
|
||||
if isinstance(existing, dict):
|
||||
merged[key] = {**existing, **value}
|
||||
else:
|
||||
merged[key] = value
|
||||
|
||||
conductor.config = json.dumps(merged, ensure_ascii=False)
|
||||
await self.db.commit()
|
||||
|
||||
return _dict_to_response(merged)
|
||||
|
||||
async def record_stat(
|
||||
self,
|
||||
user_id: str | None,
|
||||
stage: str,
|
||||
passed: bool,
|
||||
reason: str | None = None,
|
||||
duration_ms: int | None = None,
|
||||
) -> PipelineStats:
|
||||
stat = PipelineStats(
|
||||
id=str(__import__("uuid").uuid4()),
|
||||
user_id=user_id,
|
||||
stage=stage,
|
||||
passed=passed,
|
||||
reason=reason,
|
||||
duration_ms=duration_ms,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self.db.add(stat)
|
||||
await self.db.commit()
|
||||
return stat
|
||||
|
||||
async def list_stats(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
stage: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[PipelineStats]:
|
||||
query = select(PipelineStats).order_by(PipelineStats.created_at.desc())
|
||||
if user_id:
|
||||
query = query.where(PipelineStats.user_id == user_id)
|
||||
if stage:
|
||||
query = query.where(PipelineStats.stage == stage)
|
||||
result = await self.db.execute(query.offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_summary(self) -> dict[str, Any]:
|
||||
total = await self.db.execute(select(func.count(PipelineStats.id)))
|
||||
total_count = total.scalar() or 0
|
||||
|
||||
passed_count = await self.db.execute(
|
||||
select(func.count(PipelineStats.id)).where(PipelineStats.passed == True)
|
||||
)
|
||||
passed_total = passed_count.scalar() or 0
|
||||
|
||||
stage_result = await self.db.execute(
|
||||
select(PipelineStats.stage, func.count(PipelineStats.id))
|
||||
.group_by(PipelineStats.stage)
|
||||
.order_by(func.count(PipelineStats.id).desc())
|
||||
)
|
||||
stages = {row[0]: row[1] for row in stage_result.all()}
|
||||
|
||||
avg_dur = await self.db.execute(
|
||||
select(func.avg(PipelineStats.duration_ms)).where(PipelineStats.duration_ms.isnot(None))
|
||||
)
|
||||
avg_val = avg_dur.scalar()
|
||||
|
||||
fail_reason_result = await self.db.execute(
|
||||
select(PipelineStats.reason, func.count(PipelineStats.id))
|
||||
.where(
|
||||
PipelineStats.passed == False,
|
||||
PipelineStats.reason.isnot(None),
|
||||
)
|
||||
.group_by(PipelineStats.reason)
|
||||
.order_by(func.count(PipelineStats.id).desc())
|
||||
.limit(10)
|
||||
)
|
||||
fail_reasons = [(row[0], row[1]) for row in fail_reason_result.all()]
|
||||
|
||||
return {
|
||||
"total_entries": total_count,
|
||||
"stages": stages,
|
||||
"passed_ratio": round(passed_total / total_count, 4) if total_count > 0 else 0.0,
|
||||
"avg_duration_ms": round(float(avg_val), 1) if avg_val else None,
|
||||
"fail_reasons": fail_reasons,
|
||||
}
|
||||
Reference in New Issue
Block a user