/
TurGG
/
IRA
Обзор
Документация
Войти
/
TurGG
/
IRA
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app.py
1 836 строк
79 KB
Твоё Имя
final version
25 дек 2025, 15:57
25 дек 2025, 15:57
acec5f3
Код
Авторство
О чём код?
from __future__ import annotations """Streamlit-интерфейс, который проводит пользователя через все шаги пайплайна.""" import logging import os import tempfile from pathlib import Path from typing import Any, Dict, List, Sequence import streamlit as st from audiorecorder import audiorecorder from io import BytesIO from pydantic import ValidationError from pydub import AudioSegment from gigachat_client import GigaChatClientError from models import ( BusinessRequirement, InterviewQuestion, ProductContext, QualityReport, UserStory, ) from pipeline import ( build_requirements_from_transcript, check_requirements_quality, generate_interview_questions, refine_requirements_with_answers, ) from storage.repository import SessionRepository from whisper_module import transcribe_audio logger = logging.getLogger(__name__) if not logging.getLogger().handlers: logging.basicConfig(level=logging.INFO) # ffmpeg/ffprobe для аудиозаписи (локальный бандл) FFMPEG_BIN = Path(__file__).resolve().parent / "ffmpeg_tmp" / "ffmpeg" / "ffmpeg-8.0.1-essentials_build" / "bin" if FFMPEG_BIN.exists(): os.environ["PATH"] = f"{FFMPEG_BIN}{os.pathsep}{os.environ.get('PATH', '')}" ffmpeg_exe = FFMPEG_BIN / "ffmpeg.exe" ffprobe_exe = FFMPEG_BIN / "ffprobe.exe" if ffmpeg_exe.exists(): AudioSegment.converter = str(ffmpeg_exe) AudioSegment.ffmpeg = str(ffmpeg_exe) if ffprobe_exe.exists(): AudioSegment.ffprobe = str(ffprobe_exe) MANDATORY_QUESTIONS = [ {"id": "purpose", "title": "Цель продукта", "description": "Опишите ключевую цель или гипотезу, которую реализует продукт."}, {"id": "problem", "title": "Решаемая задача", "description": "Какая проблема клиента или бизнеса будет закрыта?"}, {"id": "value", "title": "Ценность", "description": "Какая конкретная ценность появится для пользователя и бизнеса?"}, {"id": "success", "title": "Критерий успеха", "description": "Как поймём, что продукт работает (метрики, результаты)?"}, ] QUESTION_TEMPLATES: Dict[str, Dict[str, Any]] = { "9x5": { "label": "9 блоков × 5 вопросов", "description": "Максимально широкий охват тем (45 вопросов, отвечать можно выборочно).", "blocks": [ { "id": "business_goals", "title": "Цели бизнеса", "description": "Стратегические приоритеты и ожидаемые KPI", "question_count": 5, }, { "id": "target_users", "title": "Целевая аудитория", "description": "Портреты пользователей и сегменты рынка", "question_count": 5, }, { "id": "pains", "title": "Проблемы и боли", "description": "С чем сейчас сталкиваются клиенты", "question_count": 5, }, { "id": "current_processes", "title": "Текущие процессы", "description": "Как задачи закрывают сегодня и где узкие места", "question_count": 5, }, { "id": "integrations", "title": "Системы и интеграции", "description": "С какими сервисами нужно стыковаться", "question_count": 5, }, { "id": "metrics", "title": "Метрики успеха", "description": "Как измеряем эффект и какие целевые значения", "question_count": 5, }, { "id": "functional", "title": "Функциональные ожидания", "description": "Какой функционал необходим пользователям", "question_count": 5, }, { "id": "non_functional", "title": "Нефункциональные требования", "description": "Скорость, надежность, безопасность, масштабируемость", "question_count": 5, }, { "id": "risks", "title": "Риски и ограничения", "description": "Что может помешать запуску или эксплуатации", "question_count": 5, }, ], }, "5x10": { "label": "5 блоков × 10 вопросов", "description": "Углублённые интервью по ключевым направлениям (50 вопросов).", "blocks": [ { "id": "strategy", "title": "Стратегия и позиционирование", "description": "Цели, рынок, конкуренты", "question_count": 10, }, { "id": "operations", "title": "Операционные процессы", "description": "Как всё устроено сейчас и что мешает росту", "question_count": 10, }, { "id": "users", "title": "Пользователи и сценарии", "description": "Кто пользуется продуктом и зачем", "question_count": 10, }, { "id": "technology", "title": "Технологии и интеграции", "description": "Стек, ограничения, безопасность", "question_count": 10, }, { "id": "success", "title": "Ценность и метрики", "description": "Как измеряем пользу и результат", "question_count": 10, }, ], }, } DEFAULT_QUESTION_TEMPLATE_ID = "default-10" QUESTION_TEMPLATES[DEFAULT_QUESTION_TEMPLATE_ID] = { "label": "Базовый блок из 10 вопросов", "description": "Генерирует 10 вопросов, которые расширяют понимание целей, пользователей и процессов.", "blocks": [ { "id": "core", "title": "Дополнительные вопросы", "description": "Общий блок без жесткой категории; акцент на ключевые аспекты бизнеса.", "question_count": 10, } ], } QUESTION_CHUNK_SIZE = 10 PRIORITY_COLOR_MAP = { "high": "#ef4444", "medium": "#f97316", "low": "#2563eb", } MANDATORY_PROCESSING_OVERLAY_CSS = """ <style> .mandatory-processing-overlay { margin-top: -130px; margin-bottom: 0.8rem; padding: 0.9rem 1.1rem; border-radius: 12px; background: rgba(15, 17, 26, 0.92); border: 1px solid rgba(255, 255, 255, 0.12); color: #f8fafc; display: flex; align-items: center; gap: 0.75rem; font-size: 0.92rem; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.45); } .mandatory-processing-overlay .loader { width: 20px; height: 20px; border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.25); border-top-color: #a855f7; animation: mandatory-spinner 0.8s linear infinite; } @keyframes mandatory-spinner { to { transform: rotate(360deg); } } </style> """ def _render_priority_legend() -> None: badges = " ".join( f"<span style='color:{color}; font-weight:600;'>● {priority.title()}</span>" for priority, color in PRIORITY_COLOR_MAP.items() ) st.markdown(f"<div style='margin-bottom:0.25rem;'>Приоритеты: {badges}</div>", unsafe_allow_html=True) STEPS = [ { "title": "Шаг 1. Генерация вопросов", "description": "Расскажите о продукте, выберите шаблон 9×5 или 5×10 и позвольте нейросети подготовить набор вопросов.", }, { "title": "Шаг 2. Обязательные и дополнительные ответы", "description": "Сначала ответьте на обязательные вопросы, затем дополните или отредактируйте блоки 9×5/5×10 — они необязательны, но помогают наполнить требования.", }, { "title": "Шаг 3. Проверка качества", "description": "LLM оценивает полноту и согласованность бизнес-требований и предлагает follow-up вопросы при необходимости.", }, { "title": "Шаг 4. Финальный отчёт", "description": "Посмотрите сформированные цели, ценности и результаты, прежде чем закрыть сессию.", }, ] DARK_THEME_CSS = """ <style> html, body, .stApp { background-color: #0f111a; color: #f5f7fb; font-family: "Segoe UI", "Inter", "Arial", sans-serif !important; } .block-container { padding-top: 2rem; padding-bottom: 3rem; } .stepper { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; } .step-card { flex: 1; border-radius: 12px; padding: 0.75rem; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); text-align: center; transition: transform 0.2s ease, border-color 0.2s ease; } .step-card.active { border-color: #a855f7; box-shadow: 0 0 12px rgba(168,85,247,0.35); transform: translateY(-2px); } .step-card.done { border-color: rgba(168,85,247,0.4); } .step-card span { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.08); margin-bottom: 0.25rem; } .step-card.active span { background: linear-gradient(135deg,#a855f7,#ec4899); } .fade-in { animation: fadeIn 0.6s ease; } @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } .stButton>button, .stForm button, button[data-testid="baseButton-primary"], button[data-testid="baseButton-secondary"], div[data-testid="baseButton-icon"] button { background: linear-gradient(135deg,#a855f7,#ec4899) !important; color: #fff !important; border: 1px solid rgba(255,255,255,0.1) !important; border-radius: 999px !important; padding: 0.6rem 1.4rem !important; font-weight: 600; transition: transform 0.2s ease, box-shadow 0.2s ease; } .stButton>button:disabled, .stForm button:disabled, button[data-testid="baseButton-primary"]:disabled, button[data-testid="baseButton-secondary"]:disabled { background: rgba(255,255,255,0.08) !important; color: rgba(255,255,255,0.4) !important; } .stButton>button:not(:disabled):hover, .stForm button:not(:disabled):hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(236,72,153,0.35); } .stTextInput>div>div>input, .stTextArea textarea, .stSelectbox>div>div>div>div, .stMultiSelect>div>div>div>div, input:not([type="checkbox"]):not([type="radio"]), textarea, div[data-baseweb="input"] > div > input, div[data-baseweb="textarea"] textarea, div[data-baseweb="select"] input, div[data-baseweb="select"] span { background: #f8fafc !important; color: #0f111a !important; border: 1px solid rgba(255,255,255,0.25) !important; border-radius: 10px !important; caret-color: #0f111a; } .stTextInput>div>div>input:focus, .stTextArea textarea:focus, input:focus, textarea:focus, .stSelectbox>div>div>div>div:focus, div[data-baseweb="select"]:focus-within { border-color: #a855f7 !important; box-shadow: 0 0 0 1px rgba(168,85,247,0.35); outline: none; } .stTextInput>div>div>input::placeholder, .stTextArea textarea::placeholder, input::placeholder, textarea::placeholder { color: rgba(15,17,26,0.45) !important; } .stPasswordInput button, div[data-baseweb="input"] button { filter: invert(1); } .stSelectbox div[data-baseweb="select"] span { color: #f5f7fb; } .stMarkdown table { color: #f5f7fb; } .streamlit-expanderHeader { color: #f5f7fb; } .stForm label { color: rgba(245,247,251,0.85) !important; } label, .stRadio label, .stSelectbox label, .stMultiSelect label, .stTextInput label, .stTextArea label, .stNumberInput label, .stCheckbox label { color: rgba(245,247,251,0.85) !important; } .history-card { border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.02); border-radius: 12px; padding: 1rem; /* Form buttons (включая st.form_submit_button) */ button[kind="primary"], button[kind="secondary"], button[kind="minimal"], button[type="submit"] { background: linear-gradient(135deg,#a855f7,#ec4899) !important; color: #fff !important; border: 1px solid rgba(255,255,255,0.1) !important; border-radius: 999px !important; } .stAudioRecorder, div[data-testid="stAudioRecorder"], div[data-testid="stAudioRecorder"] > div { background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important; } .stAudioRecorder * { background: transparent !important; border: none !important; box-shadow: none !important; } .stDownloadButton>button, .stDownloadButton>button * { font-family: "Segoe UI", "Inter", "Arial", sans-serif !important; color: #ffffff !important; } .stDownloadButton>button, button[data-testid="baseButton-secondary"], button[kind="secondary"] { background: linear-gradient(135deg,#a855f7,#ec4899) !important; border: none !important; border-radius: 12px !important; padding: 0.65rem 1rem !important; font-weight: 600 !important; box-shadow: 0 6px 16px rgba(168,85,247,0.25) !important; transition: transform 0.15s ease, box-shadow 0.15s ease !important; } .stDownloadButton>button:hover, button[data-testid="baseButton-secondary"]:hover, button[kind="secondary"]:hover { transform: translateY(-1px); box-shadow: 0 10px 20px rgba(236,72,153,0.30) !important; } .stDownloadButton>button:active, button[data-testid="baseButton-secondary"]:active, button[kind="secondary"]:active { transform: translateY(0); box-shadow: 0 4px 10px rgba(168,85,247,0.2) !important; } .stDownloadButton>button:disabled, button[data-testid="baseButton-secondary"]:disabled, button[kind="secondary"]:disabled { background: linear-gradient(135deg,rgba(168,85,247,0.75),rgba(236,72,153,0.75)) !important; color: rgba(255,255,255,0.9) !important; opacity: 1 !important; border: none !important; } .stFileUploader, .stFileUploader div[data-testid="stFileUploaderDropzone"] { background: #0f111a !important; border: 1px dashed rgba(255,255,255,0.15) !important; color: #f5f7fb !important; } .stFileUploader div[data-testid="stFileUploaderDropzone"] p { color: #f5f7fb !important; } button[kind="primary"]:disabled, button[kind="secondary"]:disabled, button[type="submit"]:disabled, .stButton>button:disabled { background: rgba(255,255,255,0.08) !important; color: rgba(255,255,255,0.4) !important; border-color: rgba(255,255,255,0.08) !important; } /* Inputs и плейсхолдеры */ .stTextInput input, .stTextArea textarea, .stNumberInput input, .stPasswordInput input, input[type="text"], input[type="password"], textarea, .stSelectbox div[data-baseweb="select"] input, .stSelectbox div[data-baseweb="select"] span, .stMultiSelect div[data-baseweb="select"] span { background: rgba(255,255,255,0.08) !important; color: #f5f7fb !important; border: 1px solid rgba(255,255,255,0.25) !important; border-radius: 10px !important; caret-color: #f5f7fb !important; } .stTextInput input::placeholder, .stTextArea textarea::placeholder, input::placeholder, textarea::placeholder { color: rgba(245,247,251,0.55) !important; } .stTextInput input:focus, .stTextArea textarea:focus, input:focus, textarea:focus, .stSelectbox div[data-baseweb="select"]:focus-within { border-color: #a855f7 !important; box-shadow: 0 0 0 1px rgba(168,85,247,0.35) !important; outline: none !important; } .stPasswordInput>div>div>div>div button, div[data-baseweb="base-button"] { color: #0f111a; } } </style> """ st.set_page_config(page_title="LLM Interview Agent", page_icon="рџ§ ", layout="wide") st.title("Агент для преобразования интервью в бизнес-требования") st.markdown(DARK_THEME_CSS, unsafe_allow_html=True) repository = SessionRepository() VALID_USERNAME = "project" VALID_PASSWORD = "project" AUTH_QUERY_KEY = "auth" def _ensure_authenticated() -> bool: """Простая учебная авторизация по фиксированным логину и паролю.""" params = st.query_params if params.get(AUTH_QUERY_KEY, ["0"])[0] == "1": st.session_state.authenticated = True if st.session_state.get("authenticated"): return True st.info("Введите учебные учётные данные, чтобы продолжить (project / project).") with st.form("login_form"): username = st.text_input("Логин") password = st.text_input("Пароль", type="password") submitted = st.form_submit_button("Войти") if submitted: if username == VALID_USERNAME and password == VALID_PASSWORD: st.session_state.authenticated = True st.success("Готово, можно продолжать 👍") st.query_params.update({AUTH_QUERY_KEY: "1"}) st.rerun() else: st.error("Неверный логин или пароль") return False def _model_dump(model: Any) -> Dict[str, Any]: """Приводит pydantic-модель к dict вне зависимости от версии Pydantic.""" if hasattr(model, "model_dump"): return model.model_dump() if hasattr(model, "dict"): return model.dict() raise TypeError(f"Unexpected model type: {type(model)}") def _parse_model(model_cls, data): """Обратно строит модель из dict (универсально для Pydantic 1/2).""" if hasattr(model_cls, "model_validate"): return model_cls.model_validate(data) return model_cls.parse_obj(data) def _get_template_meta(template_id: str) -> Dict[str, Any]: return QUESTION_TEMPLATES.get(template_id) or QUESTION_TEMPLATES[DEFAULT_QUESTION_TEMPLATE_ID] def _clear_cached_inputs() -> None: for key in ( "context_name", "context_description", "context_audience", "business_goals_text", "transcript_text", ): st.session_state.pop(key, None) # reset answer widgets for remove_key in [ k for k in st.session_state.keys() if k.startswith("answer_") or k.startswith("qa_answer_") ]: st.session_state.pop(remove_key, None) st.session_state.pop("follow_up_answers", None) st.session_state.pop("question_answers", None) def _switch_session(session_id: str) -> None: _clear_cached_inputs() st.session_state.session_id = session_id st.session_state.session_data = repository.get_session(session_id) st.session_state.question_answers = st.session_state.session_data.get("question_answers", {}) st.session_state.question_template_id = ( st.session_state.session_data.get("question_template_id") or DEFAULT_QUESTION_TEMPLATE_ID ) st.session_state.current_step = 0 st.session_state.mandatory_answers = st.session_state.session_data.get("mandatory_answers", {}) st.session_state.optional_templates = st.session_state.session_data.get("optional_templates", []) def _render_session_history(sessions: List[Dict[str, Any]]) -> None: if not sessions: return current_id = st.session_state.get("session_id") filtered_sessions = [] for record in sessions: context_name = (record.get("context") or {}).get("name") or "" if not context_name and record["id"] != current_id: repository.delete_session(record["id"]) continue filtered_sessions.append(record) sessions = filtered_sessions if not sessions: return labels = [] mapping: Dict[str, str] = {} for record in sessions: context_name = (record.get("context") or {}).get("name") or "Без названия" updated_at = (record.get("updated_at") or record.get("created_at") or "")[:16] or "не известно" label = f"{context_name} В· {updated_at}" labels.append(label) mapping[label] = record["id"] current_id = st.session_state.session_id try: current_label = next(label for label, sid in mapping.items() if sid == current_id) except StopIteration: current_label = labels[0] selected_label = st.selectbox( "�стория интервью", options=labels, index=labels.index(current_label), help="Выберите сохранённую сессию, чтобы продолжить работу с её контекстом и ответами.", ) target_session = mapping[selected_label] if target_session != current_id: _switch_session(target_session) st.rerun() with st.expander("Последние сохранения", expanded=False): history_rows = [] for record in sessions[:5]: context_name = (record.get("context") or {}).get("name") or "Без названия" updated_at = record.get("updated_at") or record.get("created_at") or "не известно" history_rows.append(f"<li><strong>{context_name}</strong> — обновлено {updated_at}</li>") if history_rows: st.markdown( "<div class='history-card'><ul>" + "".join(history_rows) + "</ul></div>", unsafe_allow_html=True, ) delete_options = [label for label in labels] with st.expander("Удаление сессий", expanded=False): delete_label = st.selectbox( "Выберите сессию для удаления", options=delete_options, help="Можно удалить и текущую сессию — будет создана новая пустая.", ) delete_id = mapping.get(delete_label) if st.button("Удалить выбранную сессию", type="secondary", key="delete_session_btn"): if delete_id: repository.delete_session(delete_id) st.success("Сессия удалена.") remaining = repository.list_sessions() if not remaining: new_id = repository.create_session() _switch_session(new_id) elif delete_id == st.session_state.session_id: _switch_session(remaining[0]["id"]) st.rerun() def _render_stepper(current_step: int) -> None: html_parts = ["<div class='stepper'>"] for idx, meta in enumerate(STEPS): status = "active" if idx == current_step else "done" if idx < current_step else "" html_parts.append( f"<div class='step-card {status} fade-in'>" f"<span>{idx + 1}</span>" f"<div>{meta['title']}</div>" f"<small>{meta['description']}</small>" "</div>" ) html_parts.append("</div>") st.markdown("".join(html_parts), unsafe_allow_html=True) def _can_go_next(step_index: int) -> tuple[bool, str | None]: if step_index >= len(STEPS) - 1: return False, None if step_index == 1: missing = [ q["title"] for q in MANDATORY_QUESTIONS if not st.session_state.mandatory_answers.get(q["id"], "").strip() ] if missing: return False, "Заполните обязательные вопросы перед переходом дальше." if step_index == 2: report_data = st.session_state.session_data.get("quality_report") if report_data: report = _parse_model(QualityReport, report_data) if report.follow_up_questions: return ( True, "Есть follow-up вопросы — можете уточнить их сейчас или продолжить без изменений.", ) return True, None def _navigation_controls() -> None: current_idx = st.session_state.get("current_step", 0) cols = st.columns(2) prev_disabled = current_idx == 0 if cols[0].button("← Назад", disabled=prev_disabled, key=f"prev_{current_idx}"): st.session_state.current_step = max(0, current_idx - 1) st.rerun() can_next, hint = _can_go_next(current_idx) if hint: cols[1].caption(hint) if current_idx < len(STEPS) - 1: if cols[1].button("Далее →", disabled=not can_next, key=f"next_{current_idx}"): st.session_state.current_step = min(len(STEPS) - 1, current_idx + 1) st.rerun() def _init_session_state() -> None: """Создаёт запись сессии и загружает её содержимое в st.session_state.""" if "session_id" not in st.session_state: session_id = repository.create_session() st.session_state.session_id = session_id st.session_state.session_data = repository.get_session(session_id) elif "session_data" not in st.session_state: st.session_state.session_data = repository.get_session(st.session_state.session_id) st.session_state.setdefault("follow_up_answers", {}) st.session_state["question_template_id"] = ( st.session_state.session_data.get("question_template_id") or st.session_state.get("question_template_id") or DEFAULT_QUESTION_TEMPLATE_ID ) st.session_state.setdefault( "question_answers", st.session_state.session_data.get("question_answers") or {}, ) st.session_state.setdefault( "mandatory_answers", st.session_state.session_data.get("mandatory_answers") or {}, ) st.session_state.setdefault( "optional_templates", st.session_state.session_data.get("optional_templates") or [], ) st.session_state.setdefault("current_step", 0) st.session_state.setdefault("pending_mandatory_transcription", None) def _save_session(**payload) -> None: """Обновляет JSON-файл и локальный session_state.""" session_id = st.session_state.session_id st.session_state.session_data = repository.update_session(session_id, **payload) st.session_state.question_template_id = ( st.session_state.session_data.get("question_template_id") or st.session_state.get("question_template_id") or DEFAULT_QUESTION_TEMPLATE_ID ) if "mandatory_answers" in payload: st.session_state.mandatory_answers = payload["mandatory_answers"] if "optional_templates" in payload: st.session_state.optional_templates = payload["optional_templates"] def _load_context() -> ProductContext | None: """Возвращает ранее сохранённый контекст, если он валиден.""" data = st.session_state.session_data.get("context") if not data: return None try: return _parse_model(ProductContext, data) except ValidationError: return None def _load_questions() -> List[InterviewQuestion]: """Загружает список вопросов из файлового хранилища.""" data = st.session_state.session_data.get("questions", []) or [] questions: List[InterviewQuestion] = [] for item in data: try: questions.append(_parse_model(InterviewQuestion, item)) except ValidationError: logger.warning("Пропущен некорректный вопрос: %s", item) return questions def _load_requirements() -> List[BusinessRequirement]: """Вытаскивает текущие бизнес-требования из session_state.""" data = st.session_state.session_data.get("requirements", []) or [] result: List[BusinessRequirement] = [] for item in data: try: result.append(_parse_model(BusinessRequirement, item)) except ValidationError: logger.warning("Пропущено некорректное требование: %s", item) return result def _load_user_stories() -> List[UserStory]: """Возвращает связанные user stories.""" data = st.session_state.session_data.get("user_stories", []) or [] stories: List[UserStory] = [] for item in data: try: stories.append(_parse_model(UserStory, item)) except ValidationError: logger.warning("Пропущена некорректная user story: %s", item) return stories def transcribe_uploaded_audio(uploaded_file) -> str: """Transcribe uploaded audio file using the existing Whisper module.""" if not uploaded_file: return "" tmp_path = None try: suffix = Path(uploaded_file.name).suffix or ".wav" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: tmp.write(uploaded_file.getvalue()) tmp_path = tmp.name result = transcribe_audio(tmp_path) if not result.get("success"): raise RuntimeError(result.get("error", "Неизвестная ошибка")) return result.get("formatted_text") or result.get("cleaned_text") or result.get("raw_text") or "" except Exception as exc: # pragma: no cover - UI helper logger.exception("Ошибка транскрибации") st.error(f"Не удалось выполнить транскрибацию: {exc}") return "" finally: if tmp_path and os.path.exists(tmp_path): os.unlink(tmp_path) _whisper_model: Any | None = None def _transcribe_with_whisper(audio_path: str) -> str: global _whisper_model try: import whisper as _whisper_module except ImportError as exc: logger.warning("Whisper fallback недоступен: %s", exc) return "" if _whisper_model is None: _whisper_model = _whisper_module.load_model("small") try: result = _whisper_model.transcribe(audio_path, language="ru") return (result.get("text") or "").strip() except Exception as exc: logger.warning("Ошибка fallback-транскрипции Whisper: %s", exc) return "" def _transcribe_audio_bytes(audio_bytes: bytes, suffix: str = ".wav") -> tuple[str, str | None]: tmp_path = None try: with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: tmp.write(audio_bytes) tmp.flush() tmp_path = tmp.name result = transcribe_audio(tmp_path) if not result.get("success"): raise RuntimeError(result.get("error", "Не удалось распознать голос.")) text = ( result.get("formatted_text") or result.get("cleaned_text") or result.get("raw_text") or "" ).strip() logger.info("Транскрипт (GigaAM): %s", text or "<пусто>") return text, None except Exception as exc: logger.warning("Ошибка распознавания аудио: %s", exc) if tmp_path: fallback = _transcribe_with_whisper(tmp_path) if fallback: logger.info("Whisper fallback вернул результат.") return fallback, None return "", str(exc) finally: if tmp_path and os.path.exists(tmp_path): os.unlink(tmp_path) def _save_mandatory_audio( question_id: str, audio_bytes: bytes, source: str, filename: str | None = None, mime: str = "audio/wav", ) -> None: if not audio_bytes: return st.session_state.mandatory_audio[question_id] = { "bytes": audio_bytes, "source": source, "filename": filename or "", "mime": mime, } def _save_optional_audio( question_id: str, audio_bytes: bytes, source: str, filename: str | None = None, mime: str = "audio/wav", ) -> None: if not audio_bytes: return st.session_state.optional_audio[question_id] = { "bytes": audio_bytes, "source": source, "filename": filename or "", "mime": mime, } def _requirements_table( requirements: Sequence[Dict[str, Any]], show_ids: bool = False ) -> List[Dict[str, str]]: """Подготавливает структуру, удобную для st.dataframe.""" rows: List[Dict[str, str]] = [] for item in requirements: row: Dict[str, str] = {} if show_ids: row["ID"] = item.get("id", "") row.update( { "Цель": item.get("goal", ""), "Проблема": item.get("problem", ""), "Ценность": item.get("value", ""), "Образ результата": item.get("result_vision", ""), } ) rows.append(row) return rows def _user_story_table( stories: Sequence[Dict[str, Any]], show_ids: bool = False ) -> List[Dict[str, str]]: """Подгоняет user stories под табличный формат.""" rows: List[Dict[str, str]] = [] for item in stories: row: Dict[str, str] = {} if show_ids: row["ID"] = item.get("id", "") row.update( { "Роль": item.get("role", ""), "Функциональность": item.get("feature", ""), "Ценность": item.get("benefit", ""), "Связанные требования": ", ".join(item.get("linked_requirements", [])), } ) rows.append(row) return rows _init_session_state() if not _ensure_authenticated(): st.stop() st.markdown("### Управление сессиями") sessions = repository.list_sessions() if sessions: _render_session_history(sessions) col_hist, col_new = st.columns([3, 1]) with col_hist: st.caption(f"ID текущей сессии: {st.session_state.session_id}") with col_new: if st.button("Начать новую сессию", width="stretch"): new_session_id = repository.create_session() _switch_session(new_session_id) st.rerun() current_step_index = st.session_state.get("current_step", 0) _render_stepper(current_step_index) show_table_ids = st.sidebar.checkbox( "Показывать системные ID в таблицах", key="show_table_ids", help="Скрывает специфические идентификаторы, чтобы интерфейс выглядел проще для заказчика.", ) st.session_state.setdefault("mandatory_audio", {}) st.session_state.setdefault("mandatory_audio_payloads", {}) st.session_state.setdefault("optional_audio", {}) st.session_state.setdefault("optional_audio_payloads", {}) def _prepare_context_state() -> None: """Заполняет поля шага 1 сохранёнными значениями.""" ctx = st.session_state.session_data.get("context") or {} st.session_state.setdefault("context_name", ctx.get("name", "")) st.session_state.setdefault("context_description", ctx.get("description", "")) st.session_state.setdefault("context_audience", ctx.get("target_audience", "")) st.session_state.setdefault("business_goals_text", "\n".join(ctx.get("business_goals", []))) def _prepare_transcript_state() -> None: """Переносит сохранённый транскрипт в textarea.""" st.session_state.setdefault("transcript_text", st.session_state.session_data.get("transcript", "")) def _group_questions_by_block(questions: Sequence[InterviewQuestion]) -> Dict[str, List[InterviewQuestion]]: priority_order = {"high": 0, "medium": 1, "low": 2} grouped: Dict[str, List[InterviewQuestion]] = {} for question in questions: grouped.setdefault(question.block, []).append(question) for block_id, block_questions in grouped.items(): grouped[block_id] = sorted( block_questions, key=lambda q: ( priority_order.get((q.priority or "").lower(), 9), q.text, ), ) return grouped def _build_transcript_from_answers( questions: Sequence[InterviewQuestion], answers: Dict[str, str] ) -> str: fragments: List[str] = [] questions_by_id = {q.id: q for q in questions} for qid, answer in answers.items(): answer = (answer or "").strip() if not answer: continue question = questions_by_id.get(qid) if not question: continue fragments.append(f"Вопрос: {question.text}\nОтвет: {answer}") return "\n\n".join(fragments) def _build_requirements_markdown(requirements: List[Dict[str, Any]], user_stories: List[Dict[str, Any]]) -> str: """Генерирует markdown-отчет по требованиям и user stories.""" req_models = [_parse_model(BusinessRequirement, r) for r in requirements] story_models = [_parse_model(UserStory, s) for s in user_stories] lines: List[str] = ["# Итоговый отчет по требованиям", ""] if req_models: for idx, req in enumerate(req_models, start=1): lines.append(f"## Требование {idx}: {req.goal}") lines.append(f"- Проблема: {req.problem}") lines.append(f"- Ценность: {req.value}") lines.append(f"- Видение результата: {req.result_vision}") if req.source_fragments: lines.append(f"- Источники/фрагменты: {'; '.join(req.source_fragments)}") lines.append("") else: lines.append("_Требования отсутствуют_") lines.append("") lines.append("# User stories") lines.append("") if story_models: for idx, story in enumerate(story_models, start=1): lines.append(f"## Story {idx}:") lines.append(f"- Роль: {story.role}") lines.append(f"- Функция: {story.feature}") lines.append(f"- Ценность: {story.benefit}") if story.linked_requirements: lines.append(f"- Связанные требования: {', '.join(story.linked_requirements)}") lines.append("") else: lines.append("_User stories отсутствуют_") return "\n".join(lines).strip() + "\n" def _build_docx_bytes(requirements: List[Dict[str, Any]], user_stories: List[Dict[str, Any]]) -> Tuple[bytes | None, str | None]: try: from docx import Document except Exception: return None, "Установите пакет python-docx для выгрузки DOCX." doc = Document() doc.add_heading("Итоговый отчет по требованиям и user stories", level=1) req_models = [_parse_model(BusinessRequirement, r) for r in requirements] story_models = [_parse_model(UserStory, s) for s in user_stories] if req_models: doc.add_heading("Бизнес-требования", level=2) for idx, req in enumerate(req_models, start=1): doc.add_heading(f"Требование {idx}: {req.goal}", level=3) doc.add_paragraph(f"Проблема: {req.problem}") doc.add_paragraph(f"Ценность: {req.value}") doc.add_paragraph(f"Видение результата: {req.result_vision}") if req.source_fragments: doc.add_paragraph(f"Источники/фрагменты: {', '.join(req.source_fragments)}") else: doc.add_paragraph("Требования отсутствуют.") doc.add_heading("User stories", level=2) if story_models: for idx, story in enumerate(story_models, start=1): doc.add_heading(f"Story {idx}", level=3) doc.add_paragraph(f"Роль: {story.role}") doc.add_paragraph(f"Функция: {story.feature}") doc.add_paragraph(f"Ценность: {story.benefit}") if story.linked_requirements: doc.add_paragraph(f"Связанные требования: {', '.join(story.linked_requirements)}") else: doc.add_paragraph("User stories отсутствуют.") buffer = BytesIO() doc.save(buffer) return buffer.getvalue(), None def _build_pdf_bytes(markdown_text: str) -> Tuple[bytes | None, str | None]: try: from fpdf import FPDF except Exception: return None, '?????????? ????? fpdf2 ??? ???????? PDF.' return None, '?????????? ????? fpdf2 ??? ???????? PDF.' pdf = FPDF() pdf.add_page() font_path = Path('C:/Windows/Fonts/arial.ttf') if font_path.exists(): pdf.add_font('ArialUnicode', '', str(font_path), uni=True) pdf.set_font('ArialUnicode', size=12) else: pdf.set_font('Helvetica', size=12) for line in markdown_text.splitlines(): pdf.cell(0, 10, txt=line, ln=1, align='L') buffer = BytesIO() pdf.output(buffer) return buffer.getvalue(), None def _current_block_labels() -> Dict[str, str]: meta = _get_template_meta(st.session_state.get("question_template_id", "9x5")) return {block["id"]: block["title"] for block in meta["blocks"]} if current_step_index == 0: st.header("Шаг 1. Контекст продукта и генерация вопросов") st.info( "Опишите компанию/проект, его ключевые цели и текущий уровень проработки. " "Чем точнее контекст, тем релевантнее получаются вопросы и требования." ) _prepare_context_state() st.text_input("Название продукта или проекта", key="context_name") st.text_area("Описание продукта и текущего интервью", key="context_description") st.text_input("Целевая аудитория", key="context_audience") st.text_area("Бизнес-цели (каждая строка — отдельная цель)", key="business_goals_text") selected_template_id = DEFAULT_QUESTION_TEMPLATE_ID template_meta = _get_template_meta(selected_template_id) st.caption(template_meta["description"]) business_goals = [ line.strip() for line in st.session_state.business_goals_text.splitlines() if line.strip() ] context = ProductContext( name=st.session_state.context_name.strip() or None, description=st.session_state.context_description.strip(), target_audience=st.session_state.context_audience.strip() or None, business_goals=business_goals, ) if st.button("Сгенерировать вопросы"): template_meta = _get_template_meta(selected_template_id) questions = generate_interview_questions(context, template_meta["blocks"]) _save_session( context=_model_dump(context), questions=[_model_dump(q) for q in questions], question_template_id=selected_template_id, question_answers={}, ) st.session_state.question_answers = {} st.session_state.visible_question_count = min( len(questions), QUESTION_CHUNK_SIZE ) st.success("Вопросы сгенерированы и сохранены.") question_records = st.session_state.session_data.get("questions", []) total_questions = len(question_records) st.subheader("Сгенерированные вопросы") _render_priority_legend() st.session_state.setdefault("visible_question_count", QUESTION_CHUNK_SIZE) visible_limit = st.session_state.get("visible_question_count", QUESTION_CHUNK_SIZE) visible_limit = min(total_questions, max(visible_limit, QUESTION_CHUNK_SIZE) if total_questions else visible_limit) if total_questions and visible_limit == 0: visible_limit = min(QUESTION_CHUNK_SIZE, total_questions) st.session_state.visible_question_count = visible_limit if total_questions: st.caption(f"Показано {visible_limit} из {total_questions} вопросов.") else: st.caption("Пока нет вопросов — нажмите «Сгенерировать вопросы», чтобы получить первые 10.") remaining_to_show = total_questions - visible_limit if remaining_to_show > 0: if st.button(f"Показать ещё {min(QUESTION_CHUNK_SIZE, remaining_to_show)} вопросов"): new_limit = min(total_questions, visible_limit + QUESTION_CHUNK_SIZE) st.session_state.visible_question_count = new_limit visible_limit = new_limit remaining_to_show = total_questions - visible_limit visible_questions = question_records[:visible_limit] if visible_limit > 0 else [] edited = st.data_editor( visible_questions, num_rows="dynamic", key="generated_questions_editor", width="stretch", column_order=["text", "priority", "block"], column_config={ "text": st.column_config.TextColumn("Вопрос", help="Текст вопроса для интервью"), "priority": st.column_config.SelectboxColumn( "Приоритет", options=["high", "medium", "low"], required=True, help="Высокий / средний / низкий приоритет", default="medium", ), "block": st.column_config.TextColumn("Блок", help="Тематика или раздел"), "id": st.column_config.Column( "ID (служебное)", disabled=True, help="Служебное поле, можно не менять" ), }, ) if st.button("Сохранить список вопросов"): edited_records = ( edited.to_dict("records") if hasattr(edited, "to_dict") else edited ) if edited_records is None: edited_records = [] normalized: List[InterviewQuestion] = [] for idx, item in enumerate(edited_records): text = (item.get("text") or "").strip() if not text: continue existing_id = None if idx < len(question_records): existing_id = (question_records[idx] or {}).get("id") question = InterviewQuestion( id=item.get("id") or existing_id or f"custom-{idx}", block=(item.get("block") or "custom").strip() or "custom", text=text, priority=(item.get("priority") or "medium").strip() or "medium", ) normalized.append(question) remaining_records = question_records[len(edited_records) :] for raw in remaining_records: try: normalized.append(_parse_model(InterviewQuestion, raw)) except ValidationError: logger.warning("Пропущен некорректный вопрос после сохранения: %s", raw) _save_session( questions=[_model_dump(q) for q in normalized], question_template_id=selected_template_id, question_answers={}, ) st.session_state.question_answers = {} st.session_state.visible_question_count = min(len(normalized), visible_limit) st.success("Список вопросов обновлён.") elif current_step_index == 1: st.header("Шаг 2. Обязательные и дополнительные вопросы") st.info( "Обязательные вопросы заполняются обязательно и описывают бизнес-цель, проблему, ценность и критерии успеха." " Далее следуют дополнительные блоки; отвечайте по возможности, но можно пропустить." ) st.caption("Сначала ответьте на обязательные вопросы ниже, затем подключите дополнительные шаблоны 9×5 или 5×10 — ответы по ним желательны, но не обязательны.") context = _load_context() st.session_state.setdefault("mandatory_processing", {}) if not st.session_state.get("mandatory_processing_style_injected"): st.markdown(MANDATORY_PROCESSING_OVERLAY_CSS, unsafe_allow_html=True) st.session_state.mandatory_processing_style_injected = True st.subheader("Обязательные вопросы") for question in MANDATORY_QUESTIONS: question_id = question["id"] pending_transcription = st.session_state.get("pending_mandatory_transcription") if pending_transcription and pending_transcription.get("question_id") == question_id: st.session_state.pending_mandatory_transcription = None transcript = "" error: str | None = None try: with st.spinner("Транскрипция обязательного ответа..."): transcript, error = _transcribe_audio_bytes( pending_transcription["audio_bytes"] ) finally: st.session_state.mandatory_processing[question_id] = False if error: st.error(f"Ошибка распознавания: {error}") if transcript: st.session_state.mandatory_answers[question_id] = transcript st.session_state.question_answers[question_id] = transcript st.success("Голосовой ответ сохранён и транскрибирован (старый текст заменён).") st.rerun() else: if not error: st.warning("Текст не распознан, попробуйте повторить ответ.") processing = bool(st.session_state.mandatory_processing.get(question_id)) default_value = st.session_state.mandatory_answers.get(question_id, '') textarea_key = f"mandatory_{question_id}_textarea" if textarea_key not in st.session_state: st.session_state[textarea_key] = default_value answer_value = st.text_area( question['title'], help=question.get('description', ''), height=120, disabled=processing, key=textarea_key, ) st.session_state.mandatory_answers[question_id] = answer_value overlay_placeholder = st.empty() if processing: overlay_placeholder.markdown( """ <div class="mandatory-processing-overlay"> <div class="loader"></div> <div> <strong>Транскрипция ответа</strong><br> Интеллект анализирует запись и вставит результат. </div> </div> """, unsafe_allow_html=True, ) else: overlay_placeholder.empty() uploaded_key = f"mandatory_audio_file_{question_id}" uploaded_q_audio = st.file_uploader( f"Загрузить аудио для вопроса «{question['title']}»", type=["mp3", "wav", "m4a"], key=uploaded_key, help="Если хотите оставить голосовой ответ, загрузите файл.", ) if uploaded_q_audio: _save_mandatory_audio( question_id, uploaded_q_audio.read(), source="Файл", filename=uploaded_q_audio.name, mime=uploaded_q_audio.type, ) recorder_key = f"mandatory_audio_recorder_{question_id}" recorder_style = { "backgroundColor": "#0f111a", # в цвет фона, убираем белую полосу "backgroundImage": "none", "color": "#ffffff", "borderRadius": "999px", "boxShadow": "none", "padding": "0.4rem 0.6rem", "border": "none", } audio_segment = audiorecorder( start_prompt="Начать запись", stop_prompt="Остановить", pause_prompt="Пауза", show_visualizer=False, key=recorder_key, custom_style=recorder_style, start_style={ "backgroundImage": "linear-gradient(135deg, #c084fc 0%, #ec4899 100%)", "color": "#ffffff", "borderRadius": "999px", "border": "none", "boxShadow": "0 10px 20px rgba(236,72,153,0.25)", }, stop_style={ "backgroundImage": "linear-gradient(135deg, #a855f7 0%, #7c3aed 100%)", "color": "#ffffff", "borderRadius": "999px", "border": "none", }, pause_style={ "backgroundColor": "#f5d0fe", "color": "#ffffff", "borderRadius": "999px", "border": "none", }, ) if len(audio_segment) > 0: buffer = BytesIO() audio_segment.export(buffer, format="wav") audio_bytes = buffer.getvalue() prev_payload = st.session_state.mandatory_audio_payloads.get(question_id) if prev_payload != audio_bytes: st.session_state.mandatory_audio_payloads[question_id] = audio_bytes _save_mandatory_audio( question_id, audio_bytes, source="Запись", mime="audio/wav", ) st.session_state.mandatory_processing[question_id] = True st.session_state.pending_mandatory_transcription = { "question_id": question_id, "audio_bytes": audio_bytes, } st.rerun() existing_audio = st.session_state.mandatory_audio.get(question_id) if existing_audio: st.audio( existing_audio["bytes"], format=existing_audio.get("mime", "audio/wav"), ) st.caption( f"Источник: {existing_audio['source']} {existing_audio.get('filename') or ''}".strip() ) if st.button("Сохранить обязательные ответы", key="save_mandatory_answers"): _save_session(mandatory_answers=st.session_state.mandatory_answers) st.success("Обязательные ответы сохранены.") st.markdown("---") st.caption("Дополнительные блоки 9×5/5×10 уже сформированы на шаге 1. При желании добавьте ещё пакет из 10 вопросов.") if st.button("Сгенерировать ещё 10 вопросов", key="add_extra_questions"): if not context: st.warning("Сначала заполните контекст на шаге 1 и нажмите кнопку генерации.") else: template_meta = _get_template_meta(st.session_state.get("question_template_id", DEFAULT_QUESTION_TEMPLATE_ID)) new_questions = generate_interview_questions(context, template_meta["blocks"]) existing_questions = _load_questions() combined_questions = existing_questions + new_questions _save_session( questions=[_model_dump(q) for q in combined_questions], question_template_id=st.session_state.get("question_template_id", DEFAULT_QUESTION_TEMPLATE_ID), question_answers=st.session_state.question_answers, ) st.session_state.visible_question_count = min( len(combined_questions), st.session_state.get("visible_question_count", QUESTION_CHUNK_SIZE) or QUESTION_CHUNK_SIZE ) st.success("Добавлено ещё 10 вопросов.") questions_for_answers = _load_questions() if questions_for_answers: st.caption("Отвечайте на те вопросы, которые подходят. Ответы полезны, но не обязательны.") st.subheader("Дополнительные вопросы для интервью") block_titles = _current_block_labels() grouped = _group_questions_by_block(questions_for_answers) st.session_state.setdefault("question_answers", st.session_state.session_data.get("question_answers", {})) for block_id, block_questions in grouped.items(): title = block_titles.get(block_id, block_id) with st.expander(f"{title} ({len(block_questions)} вопросов)", expanded=False): for idx, question in enumerate(block_questions): key = f"qa_answer_{block_id}_{question.id}_{idx}" default_value = st.session_state.question_answers.get(question.id, "") if st.session_state.get(key) != default_value: st.session_state[key] = default_value priority_color = PRIORITY_COLOR_MAP.get(question.priority, "#9ca3af") st.markdown( f"<span style='color:{priority_color}; font-weight:600;'>● {question.priority.title()}</span>", unsafe_allow_html=True, ) answer_value = st.text_area(question.text, key=key) st.session_state.question_answers[question.id] = answer_value audio_key = f"qa_audio_{block_id}_{question.id}_{idx}" uploaded_q_audio = st.file_uploader( f"Загрузить аудио для вопроса ({question.id})", type=["mp3", "wav", "m4a"], key=audio_key, accept_multiple_files=False, ) if uploaded_q_audio: audio_bytes = uploaded_q_audio.read() suffix = Path(uploaded_q_audio.name).suffix or ".wav" _save_optional_audio( question.id, audio_bytes, source="Файл", filename=uploaded_q_audio.name, mime=uploaded_q_audio.type, ) with st.spinner("Транскрибируем аудио..."): transcript_text, transcript_error = _transcribe_audio_bytes( audio_bytes, suffix=suffix ) if transcript_error: st.error(f"Ошибка распознавания: {transcript_error}") if transcript_text: st.session_state.question_answers[question.id] = transcript_text st.session_state.pop(key, None) st.success("Голосовой ответ сохранён и транскрибирован (старый текст заменён).") st.rerun() recorder_key = f"qa_recorder_{block_id}_{question.id}_{idx}" optional_recorder_style = { "backgroundColor": "#0f111a", "backgroundImage": "none", "color": "#ffffff", "borderRadius": "999px", "boxShadow": "none", "padding": "0.4rem 0.6rem", "border": "none", } audio_segment = audiorecorder( start_prompt="Начать запись", stop_prompt="Остановить", pause_prompt="Пауза", show_visualizer=False, key=recorder_key, custom_style=optional_recorder_style, start_style={ "backgroundImage": "linear-gradient(135deg, #c084fc 0%, #ec4899 100%)", "color": "#ffffff", "borderRadius": "999px", "border": "none", "boxShadow": "0 10px 20px rgba(236,72,153,0.25)", }, stop_style={ "backgroundImage": "linear-gradient(135deg, #a855f7 0%, #7c3aed 100%)", "color": "#ffffff", "borderRadius": "999px", "border": "none", }, pause_style={ "backgroundColor": "#f5d0fe", "color": "#ffffff", "borderRadius": "999px", "border": "none", }, ) if len(audio_segment) > 0: buffer = BytesIO() audio_segment.export(buffer, format="wav") audio_bytes = buffer.getvalue() prev = st.session_state.optional_audio_payloads.get(question.id) if prev != audio_bytes: st.session_state.optional_audio_payloads[question.id] = audio_bytes _save_optional_audio( question.id, audio_bytes, source="Запись", mime="audio/wav", ) with st.spinner("Принимаем и транскрибируем запись..."): transcript_text, transcript_error = _transcribe_audio_bytes( audio_bytes ) if transcript_error: st.error(f"Ошибка распознавания: {transcript_error}") if transcript_text: st.session_state.question_answers[question.id] = transcript_text st.session_state.pop(key, None) st.success("Голосовой ответ сохранён и транскрибирован (старый текст заменён).") st.rerun() existing_optional_audio = st.session_state.optional_audio.get(question.id) if existing_optional_audio: st.audio( existing_optional_audio["bytes"], format=existing_optional_audio.get("mime", "audio/wav"), ) st.caption( f"Источник: {existing_optional_audio['source']} {existing_optional_audio.get('filename') or ''}".strip() ) if st.button("Собрать ответы в текст"): prepared_answers = { qid: text for qid, text in st.session_state.question_answers.items() if text and text.strip() } if not prepared_answers: st.warning("Заполните хотя бы один дополнительный ответ, чтобы собрать текст.") else: transcript_from_answers = _build_transcript_from_answers(questions_for_answers, prepared_answers) st.session_state.transcript_text = transcript_from_answers _save_session( transcript=transcript_from_answers, question_answers=st.session_state.question_answers, mandatory_answers=st.session_state.mandatory_answers, ) st.success("Ответы собраны в транскрипт.") else: st.info("Сначала сгенерируйте дополнительные вопросы.") uploaded_file = st.file_uploader("Загрузить аудио (mp3, wav, m4a)", type=["mp3", "wav", "m4a"]) if uploaded_file and st.button("Транскрибировать аудио", key="transcribe_main_audio"): transcript = transcribe_uploaded_audio(uploaded_file) if transcript: st.session_state.transcript_text = transcript _save_session( transcript=transcript, mandatory_answers=st.session_state.mandatory_answers, ) st.success("Аудио транскрибировано.") st.text_area("Транскрипт или текст ответов", key="transcript_text", height=250) if st.button("Сохранить текст ответов"): _save_session( transcript=st.session_state.transcript_text.strip(), question_answers=st.session_state.question_answers, mandatory_answers=st.session_state.mandatory_answers, ) st.success("Текст сохранён.") st.markdown("---") st.subheader("Сформировать требования и user stories") context = _load_context() transcript = st.session_state.session_data.get("transcript") or "" questions = _load_questions() if st.button("Собрать требования и user stories"): if not context: st.warning("Сначала заполните описание продукта на шаге 1.") elif not transcript.strip(): st.warning("Сначала добавьте текст интервью или транскрипт.") else: try: requirements, user_stories = build_requirements_from_transcript( context, questions, transcript ) _save_session( requirements=[_model_dump(r) for r in requirements], user_stories=[_model_dump(u) for u in user_stories], mandatory_answers=st.session_state.mandatory_answers, ) st.success("Бизнес-требования и user stories обновлены.") except GigaChatClientError as exc: st.error(f"Ошибка GigaChat: {exc}") except ValueError as exc: st.error(str(exc)) req_records = st.session_state.session_data.get("requirements", []) stories_records = st.session_state.session_data.get("user_stories", []) if req_records: st.subheader("Бизнес-требования") st.dataframe( _requirements_table(req_records, show_ids=show_table_ids), width="stretch", ) else: st.info("Бизнес-требования ещё не сформированы.") if stories_records: st.subheader("User stories") st.dataframe( _user_story_table(stories_records, show_ids=show_table_ids), width="stretch", ) else: st.info("User stories пока нет.") elif current_step_index == 2: st.header("Шаг 3. Проверка качества (по желанию)") st.caption("Уточняющие вопросы можно пропустить и перейти к финальному результату.") req_models = _load_requirements() story_models = _load_user_stories() if st.button("Оценить требования и user stories"): try: report = check_requirements_quality(req_models, story_models) _save_session( quality_report=_model_dump(report), ) st.session_state.follow_up_answers = {} st.success("Отчёт о качестве обновлён.") except GigaChatClientError as exc: st.error(f"Ошибка GigaChat: {exc}") report_data = st.session_state.session_data.get("quality_report") if report_data: report = _parse_model(QualityReport, report_data) cols = st.columns(2) cols[0].metric("Полнота", f"{report.completeness_score:.2f}") cols[1].metric("Согласованность", f"{report.consistency_score:.2f}") st.subheader("Проблемы") if report.problems: for problem in report.problems: st.markdown( f"- **{problem.id}** ({problem.type}) — {problem.description}. " f"Связанные требования: {', '.join(problem.related_requirement_ids) or 'нет'}" ) else: st.write("Проблем не обнаружено.") st.subheader("Уточняющие вопросы") if report.follow_up_questions: for question in report.follow_up_questions: answer_key = f"answer_{question.id}" st.session_state.setdefault("follow_up_answers", {}) st.session_state.follow_up_answers.setdefault(question.id, "") if answer_key not in st.session_state: st.session_state[answer_key] = st.session_state.follow_up_answers.get(question.id, "") answer_value = st.text_area( f"{question.id}: {question.question}", key=answer_key, ) st.session_state.follow_up_answers[question.id] = answer_value # Голосовой ответ для follow-up (градиентная кнопка без белого фона) follow_recorder_style = { "backgroundColor": "#0f111a", "backgroundImage": "none", "color": "#ffffff", "borderRadius": "999px", "boxShadow": "none", "padding": "0.4rem 0.6rem", "border": "none", } follow_recorder_key = f"follow_recorder_{question.id}" audio_segment_follow = audiorecorder( start_prompt="Начать запись", stop_prompt="Остановить", pause_prompt="Пауза", show_visualizer=False, key=follow_recorder_key, custom_style=follow_recorder_style, start_style={ "backgroundImage": "linear-gradient(135deg, #c084fc 0%, #ec4899 100%)", "color": "#ffffff", "borderRadius": "999px", "border": "none", "boxShadow": "0 10px 20px rgba(236,72,153,0.25)", }, stop_style={ "backgroundImage": "linear-gradient(135deg, #a855f7 0%, #7c3aed 100%)", "color": "#ffffff", "borderRadius": "999px", "border": "none", }, pause_style={ "backgroundColor": "#f5d0fe", "color": "#4c1d95", "borderRadius": "999px", "border": "none", }, ) if len(audio_segment_follow) > 0: buffer_follow = BytesIO() audio_segment_follow.export(buffer_follow, format="wav") audio_bytes_follow = buffer_follow.getvalue() with st.spinner("Транскрибируем follow-up аудио..."): transcript_text, transcript_error = _transcribe_audio_bytes(audio_bytes_follow) if transcript_error: st.error(f"Ошибка транскрибации: {transcript_error}") if transcript_text: st.session_state[answer_key] = transcript_text st.session_state.follow_up_answers[question.id] = transcript_text st.success("Голосовой ответ добавлен в поле.") audio_key = f"follow_audio_{question.id}" uploaded_follow_audio = st.file_uploader( f"Загрузить аудио для follow-up ({question.id})", type=["mp3", "wav", "m4a"], key=audio_key, accept_multiple_files=False, ) if uploaded_follow_audio: if st.button(f"Транскрибировать follow-up {question.id}", key=f"follow_transcribe_{question.id}"): transcript = transcribe_uploaded_audio(uploaded_follow_audio) if transcript: combined = ( st.session_state[answer_key] + "\n\n" if st.session_state[answer_key].strip() else "" ) + transcript st.session_state[answer_key] = combined st.session_state.follow_up_answers[question.id] = combined st.success("Ответ обновлён и добавлен в историю.") if st.button("Обновить требования по уточнениям"): answers = { qid: value.strip() for qid, value in st.session_state.follow_up_answers.items() if value and value.strip() } if not answers: st.warning("Заполните хотя бы один уточняющий ответ.") else: try: updated_requirements, updated_stories, new_report = refine_requirements_with_answers( req_models, story_models, answers ) _save_session( requirements=[_model_dump(r) for r in updated_requirements], user_stories=[_model_dump(s) for s in updated_stories], quality_report=_model_dump(new_report), ) st.session_state.follow_up_answers = {} st.success("Требования уточнены по новым данным.") st.rerun() except GigaChatClientError as exc: st.error(f"Ошибка GigaChat: {exc}") else: st.write("Уточняющих вопросов нет — можно переходить дальше.") else: st.info("Пока нет отчёта о качестве. Запустите проверку выше.") elif current_step_index == 3: st.header("Шаг 4. Финальный результат") st.caption("Расскажите о целях, полученной ценности и следующем шаге для команды.") report_data = st.session_state.session_data.get("quality_report") if not report_data: st.info("Продолжите проверку качества на шаге 3, чтобы увидеть итоги.") else: report = _parse_model(QualityReport, report_data) if report.follow_up_questions: st.warning("Есть уточняющие вопросы. Ответьте на них, если нужно, или переходите к финалу.") else: st.success("Требования проверены и готовы к передаче команде.") if st.session_state.session_data.get("requirements"): st.subheader("Финальные бизнес-требования") st.dataframe( _requirements_table( st.session_state.session_data["requirements"], show_ids=show_table_ids, ), width="stretch", ) if st.session_state.session_data.get("user_stories"): st.subheader("Финальные user stories") st.dataframe( _user_story_table( st.session_state.session_data["user_stories"], show_ids=show_table_ids, ), width="stretch", ) st.subheader("Результаты проверки качества") st.write( f"Полнота: {report.completeness_score:.2f}, " f"Согласованность: {report.consistency_score:.2f}" ) requirements_data = st.session_state.session_data.get("requirements") or [] user_stories_data = st.session_state.session_data.get("user_stories") or [] if requirements_data or user_stories_data: st.subheader("Скачать итоговый пакет") markdown_report = _build_requirements_markdown(requirements_data, user_stories_data) st.download_button( "\u0421\u043a\u0430\u0447\u0430\u0442\u044c Markdown", data=markdown_report.encode("utf-8"), file_name="requirements.md", mime="text/markdown", ) docx_bytes, docx_error = _build_docx_bytes(requirements_data, user_stories_data) if docx_bytes: st.download_button( "\u0421\u043a\u0430\u0447\u0430\u0442\u044c DOCX", data=docx_bytes, file_name="requirements.docx", mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) elif docx_error: st.info(docx_error) pdf_bytes, pdf_error = _build_pdf_bytes(markdown_report) if pdf_bytes: st.download_button( "\u0421\u043a\u0430\u0447\u0430\u0442\u044c PDF", data=pdf_bytes, file_name="requirements.pdf", mime="application/pdf", ) elif pdf_error: st.info(pdf_error) _navigation_controls() st.markdown("---") st.caption("Прототип агента для анализа интервью • Следуйте шагам последовательно, нажимая «Далее»")