File size: 23,691 Bytes
7e2d640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135eefb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e2d640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135eefb
 
 
 
 
 
 
 
 
 
7e2d640
 
135eefb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e2d640
 
135eefb
 
7e2d640
 
 
 
 
 
 
135eefb
 
 
 
7e2d640
 
135eefb
7e2d640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135eefb
 
 
 
 
 
 
 
 
 
7e2d640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135eefb
7e2d640
 
 
 
135eefb
 
7e2d640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135eefb
 
7e2d640
 
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
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
"""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_rag_debug(debug: dict | None):
    """RAG debug block — same fields as the main chat expander."""
    if not debug:
        st.caption("Debug: нет данных RAG.")
        return
    passed = debug.get("passed_count", 0)
    total = debug.get("total_count", 0)
    st.caption(f"Поисковый запрос (после переписывания): {debug.get('search_query') or '—'}")
    st.caption(
        f"В контекст {passed} из {total} | "
        f"grounding: {debug.get('grounding') or '—'}"
        + (f" | {debug['result']}" if debug.get("result") else "")
    )
    for c in debug.get("chunks") or []:
        mark = "✅" if c.get("passed") else "⛔"
        score = c.get("rerank_score")
        score_s = f"{score:.4f}" if score is not None else "—"
        st.markdown(f"{mark} **#{c.get('rank', '?')}** rerank={score_s}")
        preview = (c.get("text") or "")[:400]
        if preview:
            st.text(preview + ("…" if len(c.get("text") or "") > 400 else ""))


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}")
            if r.get("temperature") is not None:
                st.caption(f"Temperature прогона: {r['temperature']}")
            turns = r.get("turns") or []
            if len(turns) == 1:
                st.markdown("**Сообщение сценария (дословно):**")
                st.text(turns[0])
            elif turns:
                st.markdown("**Сообщения сценария (дословно):**")
                for i, t in enumerate(turns, start=1):
                    st.text(f"[{i}] {t}")
            st.markdown("**Ответ бота:**")
            st.text(r.get("answer") or "—")
            dialog_turns = r.get("dialog_turns") or []
            if len(dialog_turns) > 1:
                st.markdown("**Debug RAG по ходам:**")
                for i, dt in enumerate(dialog_turns, start=1):
                    st.markdown(f"**Ход {i}** — «{(dt.get('user') or '')[:80]}»")
                    _render_rag_debug(dt.get("debug"))
                    if i < len(dialog_turns):
                        st.divider()
            else:
                with st.expander(
                    f"Debug RAG (в контекст {r.get('debug', {}).get('passed_count', '?')} "
                    f"из {r.get('debug', {}).get('total_count', '?')})",
                    expanded=False,
                ):
                    _render_rag_debug(r.get("debug"))


def _render_eval_run(module, db, reranker, base_prompt, scenarios, supports_tools,
                     system_template=None):
    from .runtime_live import build_runtime_live
    from .scenarios import evaluate_scenario

    if not scenarios:
        st.info("Сценариев нет. Добавьте их во вкладке «Редактор сценариев».")
        return

    temperature = float(st.session_state.get(
        "active_temperature",
        getattr(module, "DEFAULT_TEMPERATURE", 0.0),
    ))
    st.caption(
        "Прогон проверяет бота на выбранных сценариях против **текущих** промпта и базы знаний. "
        f"Temperature: **{temperature}** (как в «Настройки модели»). "
        "Удобно запускать после правок, чтобы убедиться, что ничего не сломалось."
    )
    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,
            (system_template or base_prompt),
            app_supports_tools=supports_tools,
            default_use_tools=default_use_tools,
            temperature=temperature,
            llm=st.session_state.get("llm"),
            query_rewriter=st.session_state.get("query_rewriter"),
            grounding_llm=st.session_state.get("grounding_llm"),
        )
        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, system_template=None):
    """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).
    ``system_template`` — полный system prompt (как в ручном чате); если не передан,
    используется ``base_prompt``.
    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,
                         system_template=system_template)
    with tab_edit:
        _render_eval_editor(scenarios, creds, sheet_id, supports_tools)