Spaces:
Runtime error
Runtime error
File size: 20,597 Bytes
8801440 6543a63 8801440 e139271 8801440 e139271 8801440 e139271 8801440 e139271 8801440 e139271 8801440 e139271 8801440 e139271 8801440 e139271 8801440 6543a63 8801440 6543a63 8801440 6543a63 8801440 6543a63 8801440 e139271 8801440 e139271 8801440 e139271 8801440 e139271 8801440 6543a63 8801440 e139271 8801440 6543a63 8801440 6543a63 8801440 6543a63 8801440 6543a63 8801440 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | """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)
|