/
Chaizee
/
ZenithCode_Incident-LLM-analytics
Обзор
Документация
Войти
/
Chaizee
/
ZenithCode_Incident-LLM-analytics
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
ui
app.py
685 строк
25 KB
unknown
Вывод UI результатов в отдельный модуль
09 июн 2026, 03:10
09 июн 2026, 03:10
580712b
Код
Авторство
О чём код?
from __future__ import annotations import html from datetime import datetime from io import BytesIO from textwrap import dedent from typing import Any import streamlit as st from loguru import logger from config import APP_ICON, APP_TITLE, DEFAULT_MODEL, MAX_PREVIEW, PAGES from src.data import load_excel from src.llm import LLMEngine from src.pipeline import PipelineResult, run_pipeline from src.ui import render_charts, render_summary STATE: dict[str, Any] = { "nav_page": PAGES[0], "file_bytes": None, "filename": None, "preview": None, "settings": {"model": DEFAULT_MODEL, "skip_llm": False, "use_cache": True, "region": "Омская область"}, "result": None, "ui_theme": "Светлая", } def main() -> None: st.set_page_config(page_title=APP_TITLE, page_icon=APP_ICON, layout="wide") _init_state() _sidebar() _render_styles() page = st.session_state.nav_page _render_header() _render_process_steps(page) if page == PAGES[0]: _page_upload() elif page == PAGES[1]: _page_run() elif page == PAGES[2]: _page_results() else: _page_export() def _init_state() -> None: for k, v in STATE.items(): if k not in st.session_state: st.session_state[k] = v.copy() if isinstance(v, dict) else v if st.session_state.get("nav_page") not in PAGES: st.session_state.nav_page = PAGES[0] settings = st.session_state.get("settings") if isinstance(settings, dict): settings["skip_llm"] = False def _render_styles() -> None: theme_name = st.session_state.get("ui_theme", "Светлая") is_dark = theme_name == "Тёмная" colors = { "bg_main": "#17160f" if is_dark else "#f4eee1", "surface_base": "#242114" if is_dark else "#ebdcc9", "surface_raised": "#302b1d" if is_dark else "#e6d3ba", "surface_inset": "#1f1c12" if is_dark else "#d9caae", "surface_soft": "rgba(48, 43, 29, 0.82)" if is_dark else "rgba(255, 255, 255, 0.22)", "olive_dark": "#b0b892" if is_dark else "#606642", "olive_medium": "#8f986d" if is_dark else "#888f68", "olive_light": "#606642" if is_dark else "#b0b892", "text_dark": "#f4eee1" if is_dark else "#2b2925", "text_muted": "#c6bba5" if is_dark else "#706b63", "border_color": "rgba(176, 184, 146, 0.45)" if is_dark else "rgba(96, 102, 66, 0.24)", "strong_border": "#b0b892" if is_dark else "#606642", } st.markdown( f""" <style> @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); :root {{ --bg-main: {colors["bg_main"]}; --surface-base: {colors["surface_base"]}; --surface-raised: {colors["surface_raised"]}; --surface-inset: {colors["surface_inset"]}; --surface-soft: {colors["surface_soft"]}; --olive-dark: {colors["olive_dark"]}; --olive-medium: {colors["olive_medium"]}; --olive-light: {colors["olive_light"]}; --text-dark: {colors["text_dark"]}; --text-muted: {colors["text_muted"]}; --border-color: {colors["border_color"]}; --strong-border: {colors["strong_border"]}; --shadow-soft: 0 4px 10px rgba(96, 102, 66, 0.13); --shadow-medium: 0 8px 18px rgba(96, 102, 66, 0.17); }} * {{ box-sizing: border-box; }} .stApp {{ background: radial-gradient(circle at top left, rgba(176, 184, 146, 0.22), transparent 34rem), var(--bg-main); color: var(--text-dark); font-family: 'Plus Jakarta Sans', system-ui, sans-serif; }} [data-testid="stAppViewContainer"], [data-testid="stHeader"], [data-testid="stToolbar"] {{ background: transparent; }} .block-container {{ max-width: 1420px; padding-top: 2rem; padding-bottom: 3.5rem; }} .stApp h1, .stApp h2, .stApp h3, .stApp h4, .stApp p, .stApp li, .stApp label, [data-testid="stMarkdownContainer"], [data-testid="stMarkdownContainer"] * {{ font-family: 'Plus Jakarta Sans', system-ui, sans-serif; }} .stApp .material-icons, .stApp .material-symbols-outlined, .stApp [class*="material-icons"], .stApp [class*="material-symbols"] {{ font-family: 'Material Symbols Rounded', 'Material Icons' !important; }} .stApp h1 {{ color: var(--text-dark); font-size: clamp(2.2rem, 5vw, 5.4rem); line-height: 0.95; letter-spacing: -0.06em; font-weight: 800; margin: 0.4rem 0 1rem 0; text-transform: uppercase; }} .stApp h2 {{ color: var(--text-dark); font-size: 1.45rem; font-weight: 800; letter-spacing: -0.03em; border-bottom: 2px solid rgba(96, 102, 66, 0.16); padding-bottom: 0.55rem; margin-top: 1.3rem; }} .stApp h3 {{ color: var(--text-muted); font-size: 0.88rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.055em; margin-top: 1.25rem; }} [data-testid="stMarkdownContainer"] p, [data-testid="stCaptionContainer"] {{ color: var(--text-muted); line-height: 1.65; }} .header-section {{ width: 100%; border: 1px solid rgba(255, 255, 255, 0.5); background: linear-gradient(135deg, rgba(255,255,255,0.22), rgba(255,255,255,0.06)); border-radius: 18px; padding: clamp(1.2rem, 3vw, 2rem); margin-bottom: 1.25rem; box-shadow: var(--shadow-soft); }} .header-section .eyebrow {{ color: var(--olive-dark); font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.78rem; margin-bottom: 0.4rem; }} .header-section .subtitle {{ max-width: 900px; color: var(--text-muted); font-size: 1.02rem; margin: 0; }} .signal-blockquote {{ border-left: 4px solid var(--olive-medium); background: rgba(96, 102, 66, 0.07); border-radius: 0 10px 10px 0; padding: 0.9rem 1.1rem; margin-top: 1.2rem; color: var(--olive-dark); font-style: italic; font-weight: 600; }} .process-card {{ width: 100%; border-radius: 16px; border: 1px solid rgba(255,255,255,0.5); background: var(--surface-soft); padding: 1rem 1.1rem 1.2rem; margin-bottom: 1.4rem; }} .process-title {{ color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.78rem; font-weight: 800; margin-bottom: 0.85rem; }} .process-steps {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.75rem; position: relative; }} .process-step {{ display: flex; flex-direction: column; align-items: flex-start; gap: 0.55rem; min-height: 4.8rem; position: relative; }} .step-dot {{ min-width: 2.35rem; width: 2.35rem; height: 2.35rem; border-radius: 50%; border: 2px solid var(--strong-border); background: var(--surface-base); color: var(--text-dark); display: flex; align-items: center; justify-content: center; font-weight: 800; box-shadow: var(--shadow-soft); z-index: 2; }} .process-step:not(:last-child)::after {{ content: ''; position: absolute; top: 1.175rem; left: 2.35rem; right: -0.75rem; height: 3px; background: var(--olive-medium); opacity: 0.72; transform: translateY(-50%); z-index: 1; }} .step-label {{ color: var(--text-muted); font-size: 0.82rem; line-height: 1.25; font-weight: 700; z-index: 2; max-width: 9.5rem; padding-left: 0.12rem; }} .process-step.active .step-dot {{ background: var(--olive-light); color: var(--text-dark); box-shadow: 0 0 18px rgba(176, 184, 146, 0.72); }} .process-step.done .step-dot {{ background: var(--olive-dark); color: var(--bg-main); }} [data-testid="stSidebar"] > div:first-child {{ background: var(--surface-raised); border-right: 2px solid var(--border-color); box-shadow: var(--shadow-medium); }} [data-testid="stSidebar"] h2, [data-testid="stSidebar"] h3, [data-testid="stSidebar"] p, [data-testid="stSidebar"] label, [data-testid="stSidebar"] span {{ color: var(--text-dark); }} [data-testid="stMetric"] {{ background: var(--surface-base); border: 1px solid rgba(255,255,255,0.45); border-radius: 14px; padding: 0.9rem 1rem; box-shadow: var(--shadow-medium); }} [data-testid="stMetricValue"] {{ color: var(--olive-dark); font-weight: 800; }} [data-testid="stMetricLabel"] p {{ color: var(--text-muted); font-weight: 700; }} .summary-box {{ border-left: 4px solid var(--olive-medium); padding: 1rem 1.15rem; background: rgba(96, 102, 66, 0.08); border-radius: 0 12px 12px 0; margin: 0.6rem 0 1.1rem 0; color: var(--text-dark); font-weight: 500; line-height: 1.65; box-shadow: var(--shadow-soft); white-space: pre-wrap; }} .stButton > button, .stDownloadButton > button, button[data-testid="baseButton-secondary"], button[data-testid="baseButton-primary"] {{ background: var(--surface-base) !important; border: 2px solid var(--strong-border) !important; color: var(--olive-dark) !important; border-radius: 10px !important; font-weight: 800 !important; box-shadow: var(--shadow-soft) !important; transition: transform 0.18s ease, box-shadow 0.18s ease !important; }} .stButton > button:hover, .stDownloadButton > button:hover {{ background: var(--surface-raised) !important; transform: translateY(-1px); box-shadow: var(--shadow-medium) !important; }} button[data-testid="baseButton-primary"] {{ background: var(--olive-dark) !important; color: var(--bg-main) !important; }} div[data-baseweb="select"] > div, .stTextInput input, textarea, input {{ background-color: var(--surface-inset) !important; color: var(--text-dark) !important; border-color: var(--strong-border) !important; border-radius: 10px !important; }} .stFileUploader section {{ background: var(--surface-soft) !important; border: 2px dashed var(--olive-medium) !important; border-radius: 16px !important; box-shadow: var(--shadow-soft); }} .stFileUploader section:hover {{ background: rgba(176,184,146,0.18) !important; }} .stRadio > div {{ background: rgba(96, 102, 66, 0.05); border-radius: 10px; padding: 0.35rem 0.45rem; }} [data-testid="stDataFrame"], [data-testid="stTable"] {{ border: 1px solid var(--border-color); border-radius: 14px; overflow: hidden; box-shadow: var(--shadow-soft); background: var(--surface-base); }} .stAlert {{ border-radius: 12px; box-shadow: var(--shadow-soft); }} .streamlit-expanderHeader {{ background: var(--surface-soft); border-radius: 10px; color: var(--text-dark) !important; font-weight: 700; }} div[data-testid="stExpander"] {{ border: 1px solid var(--border-color); border-radius: 12px; background: rgba(255, 255, 255, 0.10); box-shadow: var(--shadow-soft); }} hr {{ border-color: rgba(96, 102, 66, 0.18); }} @media (max-width: 1100px) {{ .process-steps {{ grid-template-columns: 1fr; }} .process-step:not(:last-child)::after {{ display: none; }} .header-section {{ padding: 1rem; }} }} </style> """, unsafe_allow_html=True, ) def _render_header() -> None: st.markdown( dedent( f""" <div class="header-section"> <div class="eyebrow">ЦИФРОВОЙ ПРОРЫВ ОМСК 2026 · {APP_ICON} {APP_TITLE}</div> <h1>Сигнал региона</h1> <p class="subtitle"> Анализ обращений граждан: загрузка Excel, классификация проблем локальной LLM, оценка тяжести, ТОП муниципалитетов и справка для руководства. </p> <div class="signal-blockquote"> Excel → LLM-классификация → тяжесть 1–5 → ТОП-10 / ТОП-3 → summary для руководства </div> </div> """ ).strip(), unsafe_allow_html=True, ) def _render_process_steps(current_page: str) -> None: current_index = PAGES.index(current_page) if current_page in PAGES else 0 step_labels = [page.split(". ", 1)[1] if ". " in page else page for page in PAGES] steps_html = [] for index, label in enumerate(step_labels): state_class = "done" if index < current_index else "active" if index == current_index else "" steps_html.append( f'<div class="process-step {state_class}">' f'<div class="step-dot">{index + 1}</div>' f'<div class="step-label">{html.escape(label)}</div>' f"</div>" ) st.markdown( '<div class="process-card">' '<div class="process-title">Этапы анализа</div>' f'<div class="process-steps">{"".join(steps_html)}</div>' "</div>", unsafe_allow_html=True, ) def _nav_next(current: str, *, label: str | None = None) -> None: try: idx = PAGES.index(current) except ValueError: return if idx >= len(PAGES) - 1: return next_page = PAGES[idx + 1] text = label or f"Далее: {next_page}" def _go() -> None: st.session_state.nav_page = next_page st.button(text, type="primary", use_container_width=True, on_click=_go) def _sidebar() -> None: st.sidebar.header("Навигация") st.sidebar.radio("Экран", PAGES, key="nav_page", label_visibility="collapsed") st.sidebar.divider() st.sidebar.write("**Внешний вид**") st.sidebar.radio( "Тема интерфейса", ["Светлая", "Тёмная"], key="ui_theme", horizontal=True, ) st.sidebar.divider() st.sidebar.markdown("**Статус**") st.sidebar.write("Файл:", "✅" if st.session_state.file_bytes else "—") st.sidebar.write("Анализ:", "✅" if st.session_state.result else "—") if st.sidebar.button("Сбросить", use_container_width=True): for k in STATE: del st.session_state[k] _init_state() st.rerun() def _page_upload() -> None: st.subheader("1. Загрузка данных") st.write( "Выберите Excel-файл с обращениями. Используются столбцы **группа тем, тема, регион, " "муниципалитет, населённый пункт** и текст обращения (**текст инцидента**, при пустом — другие поля)." ) uploaded = st.file_uploader("Excel-файл", type=["xlsx", "xls"]) if uploaded and st.button("Прочитать файл", type="primary", use_container_width=True): try: raw = uploaded.read() df = load_excel(BytesIO(raw)) st.session_state.file_bytes = raw st.session_state.filename = uploaded.name st.session_state.preview = df st.session_state.result = None st.success(f"Загружено **{len(df):,}** строк из `{uploaded.name}`.") except Exception as exc: st.error(f"Ошибка: {exc}") preview = st.session_state.preview if preview is not None: c1, c2, c3 = st.columns(3) c1.metric("Строк", f"{len(preview):,}") c2.metric("Муниципалитетов", preview["municipality"].nunique()) c3.metric("С текстом", int((preview["incident_text"].str.len() > 5).sum())) st.dataframe(preview.head(MAX_PREVIEW), use_container_width=True, hide_index=True) if st.session_state.file_bytes: _nav_next(PAGES[0], label="Далее: запуск анализа →") def _page_run() -> None: st.subheader("2. Запуск анализа") if not st.session_state.file_bytes: st.info("Сначала загрузите Excel на вкладке «Загрузка данных».") return s = st.session_state.settings s["region"] = st.text_input("Регион в справке", value=s.get("region", "Омская область")) st.session_state.settings = s st.write( "Классификация → тяжесть → суть проблемы → группировка по муниципалитетам → " "ТОП-3 / ТОП-10 → справка для руководства." ) bar = st.progress(0.0) status = st.empty() def on_progress(frac: float, msg: str) -> None: bar.progress(min(1.0, frac)) status.info(msg) if st.button("Запустить анализ загруженного файла", type="primary", use_container_width=True): try: engine = LLMEngine(model_name=s.get("model"), use_cache=s.get("use_cache", True)) result = run_pipeline( BytesIO(st.session_state.file_bytes), engine=engine, skip_llm=s.get("skip_llm", False), region_name=s.get("region", ""), progress=on_progress, ) st.session_state.result = result bar.progress(1.0) problems = result.analytics.get("problems_count", 0) if problems == 0: status.warning( "Анализ завершён, но проблем не найдено. " "Проверьте, что в файле есть текст обращений." ) else: status.success(f"Готово: {problems:,} проблем. Перейдите к результатам.") except Exception as exc: logger.exception("pipeline") status.error(f"Ошибка: {exc}") result: PipelineResult | None = st.session_state.result if result: c1, c2 = st.columns(2) c1.metric("Строк", result.analytics.get("total_rows", 0)) c2.metric("Проблем", result.analytics.get("problems_count", 0)) with st.expander("Лог"): for line in result.logs: st.text(line) _nav_next(PAGES[1], label="Далее: результаты →") def _page_results() -> None: st.subheader("3. Результаты") result: PipelineResult | None = st.session_state.result if not result: st.info("Сначала запустите анализ.") return render_summary(result.executive_summary) analytics = result.analytics chart = analytics.get("chart") if chart is not None and not chart.empty: render_charts(chart) elif analytics.get("problems_count", 0) == 0: st.warning("Проблемных обращений не выявлено — график и таблицы пусты.") else: st.warning("Данные для графиков не сформированы.") top3 = analytics.get("top3") if top3 is not None and not top3.empty: cols = [c for c in ("rank", "municipality", "problem_count", "mean_severity", "example_reason") if c in top3.columns] st.markdown("### ТОП-3") st.dataframe(top3[cols], use_container_width=True, hide_index=True) critical = analytics.get("critical") critical_count = analytics.get("critical_count", 0) st.markdown("### Критические обращения (тяжесть 5)") if critical is not None and not critical.empty: st.caption(f"Найдено {critical_count} обращений с максимальной тяжестью — они не всегда видны в среднем рейтинге муниципалитета.") display = critical.copy() display["incident_text"] = display["incident_text"].astype(str).str.slice(0, 250) display = display.rename( columns={ "incident_id": "Номер инцидента", "municipality": "Муниципалитет", "settlement": "Населённый пункт", "core_issue": "Суть проблемы", "incident_text": "Текст (фрагмент)", } ) st.dataframe(display, use_container_width=True, hide_index=True) else: st.info("Обращений с тяжестью 5 не выявлено.") _nav_next(PAGES[2], label="Далее: выгрузка →") def _page_export() -> None: st.subheader("4. Выгрузка") result: PipelineResult | None = st.session_state.result if not result: st.info("Сначала выполните анализ.") return stamp = datetime.now().strftime("%Y%m%d_%H%M%S") col1, col2 = st.columns(2) with col1: if result.excel_bytes: st.download_button( "Скачать Excel-отчёт", data=result.excel_bytes, file_name=f"incident_top_report_{stamp}.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", type="primary", use_container_width=True, ) else: st.warning("Excel-отчёт не сформирован (нет данных для ТОП-3/10).") with col2: st.download_button( "Скачать текстовую справку", data=result.executive_summary.encode("utf-8"), file_name=f"summary_{stamp}.txt", mime="text/plain", use_container_width=True, ) if __name__ == "__main__": main()