185 lines
6.2 KiB
Python
185 lines
6.2 KiB
Python
"""Presentation service — generates HTML slides from idea + analysis reports."""
|
||
|
||
import json
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Optional
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.agents.models import AgentReport
|
||
from app.models.idea import Idea
|
||
from app.services.disk_service import DiskService
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
ROLE_EMOJI = {
|
||
"coordinator": "🎯",
|
||
"task_organizer": "📋",
|
||
"business_analyst": "💼",
|
||
"legal_expert": "⚖️",
|
||
"financial_consultant": "💰",
|
||
"solution_architect": "🏗️",
|
||
"tester": "🧪",
|
||
"ui_designer": "🎨",
|
||
"smm_specialist": "📱",
|
||
"life_coach": "🌟",
|
||
"accessibility_expert": "♿",
|
||
}
|
||
|
||
ROLE_LABELS = {
|
||
"coordinator": "Координатор",
|
||
"task_organizer": "Организатор задач",
|
||
"business_analyst": "Бизнес-аналитик",
|
||
"legal_expert": "Юрист",
|
||
"financial_consultant": "Финансовый консультант",
|
||
"solution_architect": "Архитектор решения",
|
||
"tester": "Тестировщик",
|
||
"ui_designer": "UI/Дизайнер",
|
||
"smm_specialist": "SMM-специалист",
|
||
"life_coach": "Лайф-коуч",
|
||
"accessibility_expert": "Эксперт доступности",
|
||
}
|
||
|
||
|
||
REVEAL_HTML_TEMPLATE = """<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{title} — VoIdea Презентация</title>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/white.css">
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
|
||
* {{ font-family: 'Inter', sans-serif; }}
|
||
.reveal {{ font-size: 28px; }}
|
||
.reveal h1 {{ color: #2563EB; font-weight: 700; }}
|
||
.reveal h2 {{ color: #1E293B; font-weight: 600; }}
|
||
.reveal h3 {{ color: #475569; font-weight: 600; }}
|
||
.reveal .slides section {{ padding: 40px; }}
|
||
.slide-title {{ text-align: center; padding-top: 15vh; }}
|
||
.slide-title h1 {{ font-size: 3em; margin-bottom: 0.3em; }}
|
||
.slide-title .meta {{ color: #94A3B8; font-size: 0.5em; }}
|
||
.slide-role {{ text-align: left; }}
|
||
.slide-role h2 {{ font-size: 1.2em; margin-bottom: 0.5em; }}
|
||
.slide-role .emoji {{ font-size: 1.5em; margin-right: 0.3em; }}
|
||
.slide-role .content {{ font-size: 0.7em; line-height: 1.6; color: #334155; }}
|
||
.slide-exec {{ text-align: left; background: #EFF6FF; border-radius: 12px; padding: 30px !important; }}
|
||
.footer {{ position: fixed; bottom: 12px; left: 0; right: 0; text-align: center; font-size: 12px; color: #94A3B8; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="reveal"><div class="slides">
|
||
<section class="slide-title">
|
||
<h1>{title}</h1>
|
||
<div class="meta">
|
||
{author} · {status_label} · {date}
|
||
</div>
|
||
</section>
|
||
|
||
{executive_summary}
|
||
|
||
{role_slides}
|
||
|
||
<section>
|
||
<h2>Спасибо за внимание!</h2>
|
||
<p style="color:#94A3B8;font-size:0.6em;">Создано в VoIdea · {date}</p>
|
||
</section>
|
||
</div></div>
|
||
<div class="footer">VoIdea · {date}</div>
|
||
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
|
||
<script>Reveal.initialize({{ controls: true, progress: true, hash: true }});</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
class PresentationService:
|
||
def __init__(self, db: AsyncSession):
|
||
self.db = db
|
||
|
||
async def generate(
|
||
self,
|
||
idea_id: str,
|
||
roles: Optional[list[str]] = None,
|
||
user_name: str = "",
|
||
) -> Optional[str]:
|
||
"""Generate HTML presentation for an idea.
|
||
|
||
Args:
|
||
idea_id: UUID of the idea
|
||
roles: List of roles to include (None = all available)
|
||
user_name: Author display name
|
||
|
||
Returns:
|
||
HTML string or None if idea not found
|
||
"""
|
||
result = await self.db.execute(
|
||
select(Idea).where(Idea.id == idea_id)
|
||
)
|
||
idea = result.scalar_one_or_none()
|
||
if not idea:
|
||
return None
|
||
|
||
result = await self.db.execute(
|
||
select(AgentReport).where(
|
||
AgentReport.details["idea_id"].as_string() == idea_id
|
||
).order_by(AgentReport.created_at.asc())
|
||
)
|
||
reports = result.scalars().all()
|
||
|
||
if roles is not None:
|
||
reports = [
|
||
r for r in reports
|
||
if r.agent_id.replace("ai_", "") in roles
|
||
]
|
||
|
||
funnel_labels = {
|
||
"raw": "Сырая",
|
||
"validated": "Подтверждена",
|
||
"backlog": "Бэклог",
|
||
"in_progress": "В работе",
|
||
"launched": "Запущена",
|
||
"retrospective": "Ретроспектива",
|
||
}
|
||
status_label = funnel_labels.get(
|
||
idea.funnel_status or "", idea.funnel_status or "Без статуса"
|
||
)
|
||
|
||
date_str = datetime.now(timezone.utc).strftime("%d.%m.%Y")
|
||
|
||
role_slides = []
|
||
for r in reports:
|
||
role_name = r.agent_id.replace("ai_", "")
|
||
emoji = ROLE_EMOJI.get(role_name, "📄")
|
||
label = ROLE_LABELS.get(role_name, role_name)
|
||
content = r.message or "Нет данных"
|
||
|
||
role_slides.append(f"""
|
||
<section class="slide-role">
|
||
<h2><span class="emoji">{emoji}</span> {label}</h2>
|
||
<div class="content">{content}</div>
|
||
</section>""")
|
||
|
||
success_count = sum(1 for r in reports if r.success)
|
||
|
||
if role_slides:
|
||
exec_summary = f"""
|
||
<section class="slide-exec">
|
||
<h2>📊 Executive Summary</h2>
|
||
<p>Проанализировано ролей: <strong>{len(reports)}</strong></p>
|
||
<p>Успешных анализов: <strong>{success_count}</strong></p>
|
||
</section>"""
|
||
else:
|
||
exec_summary = ""
|
||
|
||
return REVEAL_HTML_TEMPLATE.format(
|
||
title=idea.title,
|
||
author=user_name or "Пользователь",
|
||
status_label=status_label,
|
||
date=date_str,
|
||
executive_summary=exec_summary,
|
||
role_slides="".join(role_slides),
|
||
)
|