308 lines
13 KiB
Python
308 lines
13 KiB
Python
"""Дирижёр — главный оркестратор VoIdeaAI.
|
|
|
|
Принимает голосовой ввод пользователя, определяет намерение,
|
|
направляет ролевому агенту, верифицирует ответ, возвращает пользователю.
|
|
Самообучение через логирование, рейтинг и историю успешных кейсов.
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
from typing import Any
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.agents.base import AgentResult, AgentStatus, BaseAgent
|
|
from app.agents.conductor_storage import (
|
|
auto_tune_user,
|
|
check_suggested_command,
|
|
get_recent_history,
|
|
get_similar_successful,
|
|
log_interaction,
|
|
)
|
|
from app.agents.role_agents import (
|
|
ALL_ROLE_AGENTS,
|
|
VERIFICATION_PROMPT,
|
|
RoleAgent,
|
|
run_role_agent,
|
|
)
|
|
from app.agents.vad import should_process_audio
|
|
from app.agents.wake_word import strip_wake_word, has_wake_word
|
|
from app.services.llm_service import chat_completion
|
|
from app.services.pipeline_service import PipelineService
|
|
from app.services.session_service import create_session, update_session_title
|
|
|
|
|
|
class ConductorAgent(BaseAgent):
|
|
name = "Дирижёр"
|
|
version = "1.0.0"
|
|
description = (
|
|
"Главный оркестратор. Принимает голосовой/текстовый ввод пользователя, "
|
|
"определяет намерение, направляет специализированному агенту, "
|
|
"верифицирует ответ и возвращает пользователю. Самообучение через рейтинг."
|
|
)
|
|
|
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
|
return AgentResult(
|
|
success=True,
|
|
message="Дирижёр: запущен. Используйте /api/v1/voice/chat для взаимодействия.",
|
|
)
|
|
|
|
async def process(
|
|
self,
|
|
user_input: str,
|
|
db: AsyncSession | None = None,
|
|
user_id: str | None = None,
|
|
session_id: str | None = None,
|
|
vad_enabled: bool | None = None,
|
|
wake_word_detected: bool | None = None,
|
|
audio_duration_ms: int | None = None,
|
|
pipeline_mode: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Process user input through the full conductor pipeline.
|
|
|
|
Args:
|
|
user_input: User's text input
|
|
db: Optional DB session for self-learning
|
|
user_id: Optional user ID for history
|
|
session_id: Optional session ID. Auto-creates if not provided.
|
|
vad_enabled: Whether VAD was used client-side
|
|
wake_word_detected: Whether wake word was detected client-side
|
|
audio_duration_ms: Duration of audio input in ms
|
|
pipeline_mode: Pipeline mode override ("fast" | "full" | "off")
|
|
|
|
Returns:
|
|
Dict with response, agent_name, confidence, verification_status,
|
|
interaction_id, session_id
|
|
"""
|
|
start = time.time()
|
|
pipeline_service = PipelineService(db) if db else None
|
|
stages_log: list[dict] = []
|
|
|
|
# ── 0. VAD filter ──
|
|
if audio_duration_ms is not None:
|
|
should_process, skip_reason = should_process_audio(
|
|
audio_duration_ms, {"enabled": vad_enabled if vad_enabled is not None else True}
|
|
)
|
|
if not should_process:
|
|
if pipeline_service:
|
|
await pipeline_service.record_stat(
|
|
user_id=user_id, stage="vad", passed=False,
|
|
reason=skip_reason, duration_ms=0,
|
|
)
|
|
return {
|
|
"response": "",
|
|
"agent_name": "",
|
|
"agent_description": "",
|
|
"confidence": 0,
|
|
"verification_status": "skipped",
|
|
"processing_time_ms": 0,
|
|
"interaction_id": "",
|
|
"session_id": session_id or "",
|
|
"suggested_command": None,
|
|
}
|
|
|
|
# ── 1. Wake word stripping ──
|
|
if wake_word_detected:
|
|
cleaned = strip_wake_word(user_input)
|
|
if cleaned:
|
|
user_input = cleaned
|
|
|
|
# ── 2. Авто-создание сессии ──
|
|
is_new_session = False
|
|
if db and user_id and not session_id:
|
|
session = await create_session(db, user_id)
|
|
session_id = str(session.id)
|
|
is_new_session = True
|
|
|
|
# ── Подготовка контекста с историей сессии ──
|
|
agent_names = [
|
|
{"name": a.name, "description": a.description}
|
|
for a in ALL_ROLE_AGENTS
|
|
]
|
|
|
|
context_parts = []
|
|
if db and user_id:
|
|
history = await get_recent_history(db, user_id, limit=5)
|
|
if history:
|
|
context_parts.append("Недавние обсуждения:\n" + "\n".join(
|
|
f"[{h['agent']}]: {h['input']} → {h['response'][:100]}"
|
|
for h in history
|
|
))
|
|
|
|
similar = await get_similar_successful(db, user_input)
|
|
if similar:
|
|
context_parts.append("Похожие успешные кейсы:\n" + "\n".join(
|
|
f"Было: {s['input'][:100]}, Ответ: {s['response'][:100]}"
|
|
for s in similar
|
|
))
|
|
|
|
llm_context = "\n\n".join(context_parts) if context_parts else None
|
|
|
|
# ── 3. Выбор агента ──
|
|
route_start = time.time()
|
|
selected_agent = await self._route(user_input, agent_names, llm_context)
|
|
if not selected_agent:
|
|
selected_agent = "Бизнес-аналитик"
|
|
route_elapsed = (time.time() - route_start) * 1000
|
|
|
|
agent = next(
|
|
(a for a in ALL_ROLE_AGENTS if a.name == selected_agent),
|
|
ALL_ROLE_AGENTS[0],
|
|
)
|
|
stages_log.append({"stage": "routing", "passed": True, "duration_ms": route_elapsed})
|
|
|
|
if pipeline_service:
|
|
await pipeline_service.record_stat(
|
|
user_id=user_id, stage="routing", passed=True,
|
|
duration_ms=int(route_elapsed),
|
|
)
|
|
|
|
# ── 4. Генерация ответа ──
|
|
response = await run_role_agent(agent, user_input, llm_context)
|
|
if not response:
|
|
response = "Не удалось обработать запрос. Проверьте API ключи."
|
|
|
|
# ── 5. Верификация ответа ──
|
|
verify_start = time.time()
|
|
verification = await self._verify(response, user_input)
|
|
verify_elapsed = (time.time() - verify_start) * 1000
|
|
confidence = verification.get("confidence", 50)
|
|
issues = verification.get("issues", [])
|
|
corrected = verification.get("corrected", "")
|
|
|
|
if corrected:
|
|
response = corrected
|
|
verification_status = "issues_found"
|
|
elif confidence >= 80:
|
|
verification_status = "verified"
|
|
elif confidence >= 50:
|
|
verification_status = "warning"
|
|
else:
|
|
verification_status = "needs_clarification"
|
|
response = (
|
|
"Извините, я не до конца уверен в ответе. "
|
|
"Не могли бы вы уточнить свой запрос?\n\n"
|
|
f"Вот что я понял: {response[:300]}"
|
|
)
|
|
|
|
stages_log.append({"stage": "verification", "passed": confidence >= 50, "duration_ms": verify_elapsed})
|
|
if pipeline_service:
|
|
await pipeline_service.record_stat(
|
|
user_id=user_id, stage="verification", passed=confidence >= 50,
|
|
reason=None if confidence >= 50 else "low_confidence",
|
|
duration_ms=int(verify_elapsed),
|
|
)
|
|
|
|
elapsed = (time.time() - start) * 1000
|
|
|
|
# ── 6. Логирование ──
|
|
interaction_id = ""
|
|
if db:
|
|
interaction_id = await log_interaction(
|
|
db=db,
|
|
user_id=user_id,
|
|
session_id=session_id,
|
|
input_text=user_input[:1000],
|
|
detected_intent=selected_agent,
|
|
selected_agent=selected_agent,
|
|
response_text=response,
|
|
processing_time_ms=elapsed,
|
|
confidence=confidence,
|
|
verification_status=verification_status,
|
|
was_auto_routed=True,
|
|
context={"issues": issues, "stages": stages_log} if issues else None,
|
|
)
|
|
|
|
# ── 7. Title generation для новой сессии ──
|
|
if db and session_id and is_new_session:
|
|
title_start = time.time()
|
|
title = await self._generate_title(user_input)
|
|
title_elapsed = (time.time() - title_start) * 1000
|
|
await update_session_title(db, session_id, title)
|
|
if pipeline_service:
|
|
await pipeline_service.record_stat(
|
|
user_id=user_id, stage="title_generation", passed=True,
|
|
duration_ms=int(title_elapsed),
|
|
)
|
|
|
|
# ── 8. Auto-tuning (каждые ~5 взаимодействий) ──
|
|
if db and user_id:
|
|
suggested_command = await check_suggested_command(db, user_id)
|
|
|
|
return {
|
|
"response": response,
|
|
"agent_name": selected_agent,
|
|
"agent_description": agent.description,
|
|
"confidence": confidence,
|
|
"verification_status": verification_status,
|
|
"processing_time_ms": round(elapsed, 1),
|
|
"interaction_id": interaction_id,
|
|
"session_id": session_id or "",
|
|
"suggested_command": suggested_command,
|
|
}
|
|
|
|
async def _route(
|
|
self,
|
|
user_input: str,
|
|
agent_names: list[dict[str, str]],
|
|
context: str | None = None,
|
|
) -> str | None:
|
|
system = "Ты — дирижёр умных ассистентов. Определи лучшего агента для ответа."
|
|
if context:
|
|
system += f"\n\nКонтекст:\n{context}"
|
|
agent_list = "\n".join(f"- {a['name']}: {a['description']}" for a in agent_names)
|
|
system += f"\n\nДоступные агенты:\n{agent_list}\n\nОтветь ТОЛЬКО именем агента."
|
|
|
|
messages = [
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user_input},
|
|
]
|
|
result = await chat_completion(messages, temperature=0.3, max_tokens=64)
|
|
if not result:
|
|
return None
|
|
result = result.strip().strip('"').strip("'")
|
|
valid = {a["name"] for a in agent_names}
|
|
return result if result in valid else None
|
|
|
|
async def _verify(
|
|
self,
|
|
response: str,
|
|
original_input: str,
|
|
) -> dict[str, Any]:
|
|
messages = [
|
|
{"role": "system", "content": VERIFICATION_PROMPT},
|
|
{
|
|
"role": "user",
|
|
"content": (
|
|
f"Запрос пользователя: {original_input}\n\n"
|
|
f"Ответ агента: {response}"
|
|
),
|
|
},
|
|
]
|
|
result = await chat_completion(messages, temperature=0.2, max_tokens=1024)
|
|
if not result:
|
|
return {"status": "verified", "confidence": 80, "issues": [], "corrected": ""}
|
|
try:
|
|
cleaned = result.strip()
|
|
if cleaned.startswith("```"):
|
|
cleaned = cleaned.split("\n", 1)[-1].rsplit("\n", 1)[0]
|
|
return json.loads(cleaned)
|
|
except (json.JSONDecodeError, KeyError):
|
|
return {"status": "verified", "confidence": 80, "issues": [], "corrected": ""}
|
|
|
|
async def _generate_title(self, user_input: str) -> str:
|
|
messages = [
|
|
{"role": "system", "content": (
|
|
"Ты — ассистент, который придумывает короткие заголовки для обсуждений. "
|
|
"Ответь одним предложением (до 7 слов), отражающим суть запроса."
|
|
)},
|
|
{"role": "user", "content": f"Придумай заголовок для обсуждения этого запроса:\n{user_input}"},
|
|
]
|
|
result = await chat_completion(messages, temperature=0.3, max_tokens=30)
|
|
if result:
|
|
return result.strip().strip('"').strip("'")[:255]
|
|
return "Новое обсуждение"
|
|
|
|
async def health_check(self) -> bool:
|
|
return True
|