"""Рабочее место менеджера сервисной компании.""" import os from datetime import datetime from pathlib import Path from uuid import uuid4 import streamlit as st from dotenv import load_dotenv from src.economics import calculate, estimate_request_usd from src.llm_service import classify from src.storage import append, snapshot ROOT = Path(__file__).resolve().parent load_dotenv(ROOT / ".env") JOURNAL = Path(os.getenv("LEADS_FILE", ROOT / "data" / "processed_leads.xlsx")) LABELS = {"urgent": "Срочно", "normal": "Не срочно", "spam": "Спам"} COLORS = {"urgent": "red", "normal": "green", "spam": "gray"} st.set_page_config(page_title="AI Lead Processor", layout="wide") st.title("AI Lead Processor — обработка клиентских заявок") st.write( "ИИ анализирует входящие обращения, определяет их приоритет и подготавливает черновики ответов." ) mode = st.radio("Режим обработки", ["REAL AI", "DEMO"], horizontal=True, key="mode") if mode == "DEMO": st.info("DEMO · Локальные правила. Можно проверить весь процесс без ключа.") else: st.caption( "REAL AI · Текст передаётся в OpenAI. Для демонстрации используйте синтетические обращения." ) def discard_pending() -> None: st.session_state.pop("pending", None) text = st.text_area( "Введите текст заявки клиента", key="lead", max_chars=4000, placeholder="Например, из стиральной машины течёт вода на пол.", on_change=discard_pending, ) if st.button("Обработать заявку", key="process", type="primary"): discard_pending() try: with st.spinner("Обработка заявки…"): result = classify(text, mode) st.session_state.pending = (str(uuid4()), text.strip(), mode, result) except ValueError as exc: st.error(str(exc)) if mode == "REAL AI": st.caption( "Для демонстрации выберите DEMO и снова нажмите «Обработать заявку»." ) pending = st.session_state.get("pending") if pending and pending[2] != mode: discard_pending() pending = None if pending: uid, original, used_mode, result = pending st.subheader("Проверка заявки") st.caption("Ожидает проверки менеджером") st.markdown(f":{COLORS[result.category]}-background[**{LABELS[result.category]}**]") st.text(result.reason) usage = f"{used_mode} · {result.model}" if used_mode == "REAL AI" and result.input_tokens is not None: usage += f" · Входные токены: {result.input_tokens} · Выходные токены: {result.output_tokens}" st.caption(usage) category = st.selectbox( "Категория после проверки", list(LABELS), index=list(LABELS).index(result.category), format_func=LABELS.get, key=f"category-{uid}", ) reply = "" if category != "spam": reply = st.text_area( "Черновик ответа клиенту", value=result.draft_reply, max_chars=4000, key=f"reply-{uid}", ) if st.button("Подтвердить и сохранить", key="save"): try: if category != "spam" and not reply.strip(): raise ValueError("Заполните черновик ответа перед сохранением.") cost = ( 0.0 if used_mode == "DEMO" else estimate_request_usd( result.model, result.input_tokens, result.output_tokens, result.cached_tokens or 0, ) ) reason = result.reason if category != result.category: reason = f"Менеджер изменил категорию «{LABELS[result.category]}» на «{LABELS[category]}». Исходная оценка: {reason}" append( JOURNAL, { "ID": uid, "Дата и время": datetime.now() .astimezone() .isoformat(timespec="seconds"), "Текст заявки": original, "Категория": LABELS[category], "Причина классификации": reason, "Черновик ответа": reply.strip(), "Статус проверки": "Проверено менеджером", "Режим обработки": used_mode, "Модель": result.model, "Входные токены": result.input_tokens, "Выходные токены": result.output_tokens, "Оценочная стоимость API-запроса, USD": cost, }, ) discard_pending() st.session_state.saved = True st.rerun() except ValueError as exc: st.error(str(exc)) except OSError: st.error( "Не удалось сохранить журнал. Закройте книгу в Excel, проверьте права на папку и повторите сохранение." ) if st.session_state.pop("saved", False): st.success("Заявка сохранена. Ответ клиенту отправляет менеджер.") st.subheader("История обработанных заявок") try: rows, workbook = snapshot(JOURNAL) if rows: st.dataframe(rows[::-1], hide_index=True, width="stretch") else: st.caption("Здесь появятся заявки, которые вы подтвердите.") st.download_button( "Скачать Excel", workbook, file_name="processed_leads.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) except (ValueError, OSError): st.error( "Не удалось открыть журнал. Проверьте права доступа, целостность книги и заголовки листа «Заявки»." ) with st.expander("Экономический эффект"): st.caption( "Иллюстративный сценарий для gpt-5-mini. Один запрос на заявку, 600 входных и 250 выходных токенов, без кэша." ) a, b, c = st.columns(3) count = a.number_input("Заявок в неделю", min_value=0, value=120) manual = b.number_input("Ручная обработка, мин", min_value=0.0, value=4.0) review = c.number_input("Проверка и завершение, мин", min_value=0.0, value=1.5) hourly = a.number_input("Стоимость часа, ₽", min_value=0.0, value=500.0) rate = b.number_input("Условный курс, ₽ за доллар", min_value=0.0, value=90.0) weeks = c.number_input("Недель в месяце", min_value=0.0, value=4.33) e = calculate(count, manual, review, weeks, hourly, rate) st.write( f"Время за неделю: {e.before_hours:g} ч вручную → {e.after_hours:g} ч с проверкой. " f"Экономия: {e.saved_hours:g} ч в неделю и {e.monthly_saved_hours:.2f} ч в месяц." ) st.write( f"API: {e.request_usd:.5f} USD за заявку · {e.weekly_api_usd:.4f} USD в неделю · {e.monthly_api_usd:.4f} USD в месяц." ) st.write( f"Оценка высвобождённого времени за вычетом API: {e.weekly_net_rub:.2f} ₽ в неделю " f"и {e.monthly_net_rub:.2f} ₽ в месяц. Фактическая экономия фонда оплаты труда зависит от загрузки." )