Files

177 lines
8.2 KiB
Python

"""Рабочее место менеджера сервисной компании."""
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} ₽ в месяц. Фактическая экономия фонда оплаты труда зависит от загрузки."
)