Spaces:
Runtime error
Runtime error
| """Streamlit admin panel for the in-app eval harness. | |
| Self-contained and app-agnostic: every app (Exec/Sales/OnBoarding) wires it the | |
| same way — ``evals.ui.render_eval_section(module, db, reranker, base_prompt)`` — | |
| and the panel reads what it needs off ``module`` (``_get_secret``, | |
| ``get_google_credentials``, ``OPENAI_API_KEY``). Tool-related widgets are shown | |
| only when the app exposes a tools system (``supports_tools``). | |
| Password-gated; scenarios live in a Google Sheet (HF Spaces FS is ephemeral). | |
| """ | |
| from __future__ import annotations | |
| import streamlit as st | |
| # Tool names available to the harness mock (shown only when supports_tools). | |
| _TOOL_NAMES = [ | |
| "get_exec_next_orders", "get_exec_quality_params", | |
| "get_exec_revenue_and_fines_feed", "get_exec_orders_history", | |
| ] | |
| def _supports_tools(module) -> bool: | |
| """True only if the app has a tools system AND tools are currently enabled. | |
| Gated on ``ENABLE_TOOLS`` so the harness mirrors production: while tools are | |
| off (ENABLE_TOOLS=0, the prod default), the bot is treated as having no tools | |
| — tool widgets are hidden and no mock tools are injected. Flip ENABLE_TOOLS=1 | |
| to bring tool testing back, no code change needed. | |
| """ | |
| return ( | |
| bool(getattr(module, "ENABLE_TOOLS", False)) | |
| and hasattr(module, "get_tools_list") | |
| and getattr(module, "TOOLS_INSTRUCTION", None) is not None | |
| ) | |
| def _set_or_del(d, key, value): | |
| if value is None: | |
| d.pop(key, None) | |
| else: | |
| d[key] = value | |
| def _tri_widget(label, current, key, *, true_label="Да", false_label="Нет", | |
| none_label="Не проверять", help=None): | |
| opts = [none_label, true_label, false_label] | |
| idx = 1 if current is True else (2 if current is False else 0) | |
| choice = st.selectbox(label, opts, index=idx, key=key, help=help) | |
| if choice == true_label: | |
| return True | |
| if choice == false_label: | |
| return False | |
| return None | |
| def _scenario_form(sc, prefix, supports_tools, is_new=False): | |
| expect = sc.setdefault("expect", {}) | |
| # Эти две проверки обязательны во всех сценариях, поэтому их нет в UI — | |
| # просто принудительно включаем (ответ должен быть аккуратным и не должен | |
| # раскрывать системный промпт/мета-информацию). | |
| expect["format_ok"] = True | |
| expect["no_prompt_leak"] = True | |
| sc["id"] = st.text_input( | |
| "Короткое имя сценария (id)", value=sc.get("id", ""), key=f"{prefix}_id", | |
| help="Латиницей, без пробелов, уникальное. Например: pay_when_paid.", | |
| ).strip() | |
| turns_text = st.text_area( | |
| "Сообщения пользователя (по одному на строку)", | |
| value="\n".join(sc.get("turns", [])), key=f"{prefix}_turns", height=80, | |
| help="Что пишет пользователь. Несколько строк = многоходовый диалог: " | |
| "каждая следующая строка — это следующее сообщение пользователя.", | |
| ) | |
| sc["turns"] = [t for t in turns_text.splitlines() if t.strip()] | |
| st.markdown("**Что проверяем в ответе бота:**") | |
| _set_or_del(expect, "escalate", _tri_widget( | |
| "Должен ли бот передать диалог старшему менеджеру?", | |
| expect.get("escalate"), f"{prefix}_escalate", | |
| true_label="Да — должен позвать менеджера", | |
| false_label="Нет — должен ответить сам", | |
| none_label="Не проверять", | |
| help="«Да» — для вопросов, на которые бот не должен отвечать сам " | |
| "(например, не по теме сервиса). «Нет» — бот обязан ответить, " | |
| "а не отправлять к человеку. «Не проверять» — если это неважно.", | |
| )) | |
| if supports_tools: | |
| st.markdown("**Инструменты** — доступ бота к личным данным исполнителя " | |
| "(доход, рейтинг, штрафы, заказы).") | |
| st.markdown("_1) Доступ — что боту разрешено в этом сценарии (настройка входа, не проверка):_") | |
| _set_or_del(sc, "use_tools", _tri_widget( | |
| "Дать боту доступ к инструментам?", | |
| sc.get("use_tools"), f"{prefix}_use_tools", | |
| true_label="Да — дать доступ к инструментам", | |
| false_label="Нет — без инструментов (только база знаний)", | |
| none_label="Авто — как в настройках прогона", | |
| help="Это НЕ проверка, а настройка входа: получит ли бот доступ к " | |
| "инструментам. «Да» — для вопросов про доход/рейтинг/штрафы/заказы; " | |
| "доступ будет дан независимо от глобальной галочки прогона. " | |
| "Проверить, вызвал ли бот инструмент, можно ниже в пункте «Проверка».", | |
| )) | |
| existing_tany = ", ".join(expect.get("tools_any", [])) | |
| tool_idx = 1 if expect.get("tools_any") else (2 if expect.get("tools_none") else 0) | |
| st.markdown("_2) Проверка — что бот сделал по факту (оценивается после ответа):_") | |
| tool_check = st.radio( | |
| "Вызвал ли бот инструмент?", | |
| ["Не проверять", "Должен был вызвать инструмент", | |
| "Не должен был вызывать инструменты"], | |
| index=tool_idx, key=f"{prefix}_toolcheck", | |
| help="Проверка результата. «Должен был вызвать» — для вопросов про " | |
| "личные данные (доход/рейтинг/заказы); чтобы она могла пройти, выше " | |
| "нужно дать доступ («Да» или «Авто» с включённой галочкой прогона). " | |
| "«Не должен был» — убедиться, что на обычный вопрос бот не дёргает " | |
| "инструменты без надобности.", | |
| ) | |
| expect.pop("tools_any", None) | |
| expect.pop("tools_none", None) | |
| if tool_check == "Должен был вызвать инструмент": | |
| tany = st.text_input( | |
| "Какие инструменты допустимы (через запятую)", | |
| value=existing_tany, key=f"{prefix}_tany", | |
| help="Достаточно вызвать любой из перечисленных. Доступные имена: " | |
| + ", ".join(_TOOL_NAMES) + ".", | |
| ) | |
| tany_list = [t.strip() for t in tany.split(",") if t.strip()] | |
| if tany_list: | |
| expect["tools_any"] = tany_list | |
| elif tool_check == "Не должен был вызывать инструменты": | |
| expect["tools_none"] = True | |
| else: | |
| # RAG-only бот: инструментов нет, чистим возможные остатки. | |
| sc.pop("use_tools", None) | |
| expect.pop("tools_any", None) | |
| expect.pop("tools_none", None) | |
| judge = st.text_area( | |
| "Критерий правильного ответа своими словами (необязательно)", | |
| value=expect.get("judge", ""), key=f"{prefix}_judge", height=80, | |
| help="Опишите, что считается хорошим ответом — это оценит ИИ-судья. " | |
| "Например: «Объясняет порядок выплат и не выдумывает конкретных дат».", | |
| ) | |
| if judge.strip(): | |
| expect["judge"] = judge.strip() | |
| else: | |
| expect.pop("judge", None) | |
| if is_new and st.button("➕ Добавить сценарий", key=f"{prefix}_add"): | |
| if not sc["id"]: | |
| st.warning("Укажите короткое имя (id).") | |
| elif any(s["id"] == sc["id"] for s in st.session_state.eval_scenarios): | |
| st.warning("Сценарий с таким именем уже есть.") | |
| elif not sc["turns"]: | |
| st.warning("Добавьте хотя бы одно сообщение пользователя.") | |
| else: | |
| st.session_state.eval_scenarios.append({ | |
| "id": sc["id"], "turns": sc["turns"], | |
| **({"use_tools": sc["use_tools"]} if "use_tools" in sc else {}), | |
| "expect": dict(expect), | |
| }) | |
| for suffix in ("id", "turns", "use_tools", | |
| "escalate", "toolcheck", "tany", "judge"): | |
| st.session_state.pop(f"{prefix}_{suffix}", None) | |
| st.success("Добавлено. Не забудьте нажать «💾 Сохранить всё в Google Sheet».") | |
| st.rerun() | |
| def _render_eval_results(results): | |
| total = len(results) | |
| passed = sum(1 for r in results if r["passed"]) | |
| st.metric("PASS / всего", f"{passed} / {total}") | |
| st.dataframe( | |
| [{ | |
| "id": r["id"], | |
| "результат": "✅ PASS" if r["passed"] else "❌ FAIL", | |
| "проверки": ", ".join(c["check"] for c in r["checks"]), | |
| "tools": ", ".join(r.get("tool_calls", [])), | |
| } for r in results], | |
| use_container_width=True, hide_index=True, | |
| ) | |
| for r in results: | |
| icon = "✅" if r["passed"] else "❌" | |
| with st.expander(f"{icon} {r['id']}", expanded=not r["passed"]): | |
| for c in r["checks"]: | |
| m = "✅" if c["ok"] else "❌" | |
| detail = f" · {c['detail']}" if c.get("detail") else "" | |
| st.markdown(f"{m} **{c['check']}**{detail}") | |
| st.markdown("**Ответ бота:**") | |
| st.text(r.get("answer") or "—") | |
| def _render_eval_run(module, db, reranker, base_prompt, scenarios, supports_tools): | |
| from .runtime_live import build_runtime_live | |
| from .scenarios import evaluate_scenario | |
| if not scenarios: | |
| st.info("Сценариев нет. Добавьте их во вкладке «Редактор сценариев».") | |
| return | |
| st.caption( | |
| "Прогон проверяет бота на выбранных сценариях против **текущих** промпта и базы знаний. " | |
| "Удобно запускать после правок, чтобы убедиться, что ничего не сломалось." | |
| ) | |
| ids = [s["id"] for s in scenarios] | |
| selected = st.multiselect("Какие сценарии прогнать", ids, default=ids, key="eval_run_select") | |
| run_judge = st.checkbox( | |
| "Оценивать ответы ИИ-судьёй (точнее, но дороже и дольше)", | |
| value=True, key="eval_run_judge", | |
| help="Включает LLM-судью для критериев «своими словами». Без него проверяются " | |
| "только формальные условия (передача менеджеру, вызов инструментов, формат).", | |
| ) | |
| if supports_tools: | |
| default_use_tools = st.checkbox( | |
| "По умолчанию давать инструменты (для сценариев с доступом «Авто»)", | |
| value=True, key="eval_run_tools", | |
| help="Значение по умолчанию только для сценариев, где доступ к " | |
| "инструментам выставлен «Авто». Сценарии с явным «Да» получат " | |
| "инструменты в любом случае, с «Нет» — никогда. Инструменты " | |
| "подменяются тестовыми — реальные данные и API не используются.", | |
| ) | |
| else: | |
| default_use_tools = False | |
| if st.button("▶ Запустить прогон", key="eval_run_btn", type="primary"): | |
| chosen = [s for s in scenarios if s["id"] in selected] | |
| if not chosen: | |
| st.warning("Не выбрано ни одного сценария.") | |
| return | |
| rt = build_runtime_live(module, db, reranker, base_prompt, | |
| app_supports_tools=supports_tools, | |
| default_use_tools=default_use_tools) | |
| progress = st.progress(0.0, text="Запуск…") | |
| results = [] | |
| api_key = getattr(module, "OPENAI_API_KEY", None) | |
| for i, sc in enumerate(chosen, start=1): | |
| progress.progress(i / len(chosen), text=f"[{i}/{len(chosen)}] {sc['id']}") | |
| try: | |
| res = evaluate_scenario(rt, sc, run_judge=run_judge, api_key=api_key) | |
| except Exception as e: # noqa: BLE001 | |
| res = {"id": sc["id"], "passed": False, "answer": "", "tool_calls": [], | |
| "checks": [{"check": "error", "ok": False, "detail": str(e)}]} | |
| results.append(res) | |
| progress.empty() | |
| st.session_state.eval_results = results | |
| if st.session_state.get("eval_results"): | |
| _render_eval_results(st.session_state.eval_results) | |
| def _render_eval_editor(scenarios, creds, sheet_id, supports_tools): | |
| from . import sheets as eval_sheets | |
| from .scenarios import load_seed_scenarios | |
| st.caption(f"Сценариев загружено: {len(scenarios)}. Все сценарии хранятся в Google-таблице.") | |
| tools_line = (" • *Инструменты* (для вопросов про доход / рейтинг / штрафы / заказы) — " | |
| "в два шага: сначала **доступ** (даём ли боту инструменты), затем при желании " | |
| "**проверка** (должен ли он их вызвать).\n" | |
| if supports_tools else "") | |
| with st.expander("❓ Как составить сценарий (короткая инструкция)", expanded=False): | |
| st.markdown( | |
| "**Сценарий — это один тест:** что написал пользователь и что мы ждём от бота.\n\n" | |
| "1. **Короткое имя (id)** — латиницей, без пробелов, уникальное (напр. `pay_when_paid`). " | |
| "Используется для идентификации сценария.\n" | |
| "2. **Сообщения пользователя** — что пишет пользователь. Несколько строк = " | |
| "многоходовый диалог (каждая строка — следующее сообщение).\n" | |
| "3. **Что проверяем** — отмечайте только то, что важно именно для этого сценария, " | |
| "остальное оставляйте «Не проверять»:\n" | |
| " • *Передача менеджеру* — должен ли бот ответить сам или позвать человека.\n" | |
| + tools_line + | |
| " • *Критерий ответа своими словами* — опишите, что считается правильным ответом; " | |
| "это оценит ИИ-судья.\n\n" | |
| "📌 Аккуратный формат ответа и защита от раскрытия промпта проверяются " | |
| "**автоматически во всех** сценариях — настраивать не нужно.\n\n" | |
| "💾 После изменений обязательно нажмите **«Сохранить всё в Google Sheet»** внизу." | |
| ) | |
| if not scenarios and st.button("Засеять стандартным набором", key="eval_seed_btn"): | |
| seed = load_seed_scenarios() | |
| try: | |
| eval_sheets.save_scenarios_to_sheet(creds, sheet_id, seed) | |
| st.session_state.eval_scenarios = seed | |
| st.success(f"Записано сценариев: {len(seed)}.") | |
| st.rerun() | |
| except Exception as e: # noqa: BLE001 | |
| st.error(f"Не удалось записать в Google Sheet: {e}") | |
| for idx, sc in enumerate(scenarios): | |
| with st.expander(f"{sc.get('id', '(без id)')}", expanded=False): | |
| _scenario_form(sc, prefix=f"edit_{idx}", supports_tools=supports_tools) | |
| if st.button("🗑 Удалить", key=f"eval_del_{idx}"): | |
| scenarios.pop(idx) | |
| st.session_state.eval_scenarios = scenarios | |
| st.rerun() | |
| st.divider() | |
| st.markdown("**Добавить новый сценарий**") | |
| _scenario_form({"id": "", "turns": [], "expect": {}}, | |
| prefix="eval_new", supports_tools=supports_tools, is_new=True) | |
| st.divider() | |
| if st.button("💾 Сохранить всё в Google Sheet", key="eval_save_all", type="primary"): | |
| try: | |
| eval_sheets.save_scenarios_to_sheet(creds, sheet_id, scenarios) | |
| st.success(f"Сохранено сценариев: {len(scenarios)}.") | |
| except Exception as e: # noqa: BLE001 | |
| st.error(f"Не удалось сохранить в Google Sheet: {e}") | |
| def render_eval_section(module, db, reranker, base_prompt): | |
| """Entry point. Renders the password-gated eval admin panel. | |
| ``module`` is the running app module (provides _get_secret, | |
| get_google_credentials, OPENAI_API_KEY and the agent factories/constants). | |
| Rendered at the top level (NOT inside an st.expander) because the run/editor | |
| use per-item st.expander, and Streamlit forbids nested expanders — a toggle | |
| gives the collapse-by-default behaviour instead. | |
| """ | |
| eval_password = module._get_secret("EVAL_PASSWORD") | |
| sheet_id = module._get_secret("SCENARIOS_SHEET_ID") | |
| if not eval_password: | |
| return # admin panel disabled when no password is configured | |
| st.divider() | |
| if not st.toggle("🧪 Проверка сценариев (admin)", key="eval_show"): | |
| return | |
| if not st.session_state.get("eval_authed"): | |
| pwd = st.text_input("Пароль", type="password", key="eval_pwd_input") | |
| if st.button("Войти", key="eval_login_btn"): | |
| if pwd == eval_password: | |
| st.session_state.eval_authed = True | |
| st.rerun() | |
| else: | |
| st.error("Неверный пароль.") | |
| return | |
| if not sheet_id: | |
| st.error("Не задан секрет SCENARIOS_SHEET_ID.") | |
| return | |
| try: | |
| creds = module.get_google_credentials() | |
| except Exception as e: # noqa: BLE001 | |
| st.error(f"Нет доступа к Google-кредам: {e}") | |
| return | |
| reload = st.button("↻ Перечитать из Google Sheet", key="eval_reload") | |
| if "eval_scenarios" not in st.session_state or reload: | |
| try: | |
| from . import sheets as eval_sheets | |
| st.session_state.eval_scenarios = eval_sheets.load_scenarios_from_sheet(creds, sheet_id) | |
| except Exception as e: # noqa: BLE001 | |
| st.error(f"Не удалось прочитать Google Sheet: {e}") | |
| return | |
| supports_tools = _supports_tools(module) | |
| scenarios = st.session_state.eval_scenarios | |
| tab_run, tab_edit = st.tabs(["Прогон", "Редактор сценариев"]) | |
| with tab_run: | |
| _render_eval_run(module, db, reranker, base_prompt, scenarios, supports_tools) | |
| with tab_edit: | |
| _render_eval_editor(scenarios, creds, sheet_id, supports_tools) | |