feat(sprint8b): регрессия ответов веток · general_info + фикс PRAGMA foreign_keys
Параллель к 8a, но проверяем не код intent от роутера, а содержимое ответа
конкретной ветки на одиночную реплику. Старт — general_info, 46 кейсов.
Логика pass/fail (для одного кейса):
- A — RAG-секция: среди retrieved-чанков есть кусок с
section == expected_doc_section (точное совпадение). Если поле не задано —
пропускаем.
- B — keywords: обязательные expected_keywords встречаются в predicted_answer
(case-insensitive). По умолчанию все; поддерживаются keywords_min: N
и keywords_any: true. Запрещённые expected_must_not — ни одного.
- Pass = A ∧ B. Незаданные поля не проверяются.
- Кэш: (text_hash, branch_config_id) → {answer_text, retrieved_sections}.
Привязан к версии промпта ветки. Смена версии = пустой кэш = свежий прогон.
Правка JSONL без изменения text → pass/fail пересчитывается без LLM.
Backend:
- Таблицы eval_branch_runs / eval_branch_run_cases / eval_branch_predictions.
Миграция m9g1f7e89j56.
- services/eval_branch_run_service.py: загрузка JSONL, фоновый прогон через
asyncio.create_task, кэш, оценка A+B с поддержкой keywords_min/keywords_any.
- chat_service.run_branch_single_turn — изолированный single-turn без
роутера и треда (использует существующий config_service + vectorstore + llm).
- API: POST /eval/branch-runs, GET /eval/branch-runs?intent_code=,
GET /eval/branch-runs/{id}, GET /eval/branch-cases-with-status?intent_code=.
UI (static/regression.html):
- Селектор режима «Роутер / Ветка · general_info». Логика пикера переиспользуется
(фильтры, диапазон, массовый выбор, счётчик «новые / в кэше»).
- Для режима «Ветка»: фильтр по coverage, колонки секция/coverage, keywords,
частота, кэш. Drill-down прогона: ожидание, retrieved-секции, причины fail,
полный ответ ветки.
База кейсов (eval/branch_cases_general_info.jsonl) — от пользователя, 46 кейсов
по схеме {text, intent, coverage, expected_doc_section?, expected_keywords?,
expected_must_not?, keywords_min?, keywords_any?, count?, note?}.
Связанная правка SQLite (нашли при удалении документа в этом спринте):
- db/session.py: connect-listener PRAGMA foreign_keys=ON на каждое подключение.
Без этого ondelete=CASCADE в SQLite не enforced, и удаление документа
оставляло подписки в intent_documents висячими (что давало пустой RAG
и fail регрессии).
- Миграция n0h2g8f9a0k67 — одноразовая чистка существующих висячих подписок.
docs/SPRINTS.md: Спринт 8b → ✅ Закрыт. Diff vs предыдущий прогон для веток
и кнопка «Сбросить кэш регрессии» вынесены в docs/BACKLOG.md.
Также включены обновлённые data/datasets/general_info.md и price_question.md
(рабочий материал оператора), и черновик eval/branch_cases_price_question.jsonl
для следующего захода (8b на price_question).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,64 @@ def _eval_pending_guard(
|
||||
return None
|
||||
|
||||
|
||||
async def run_branch_single_turn(
|
||||
session: AsyncSession,
|
||||
vectorstore: VectorStoreService,
|
||||
llm: LLMClient,
|
||||
intent_code: str,
|
||||
text: str,
|
||||
*,
|
||||
top_k: int = 5,
|
||||
temperature: float = 0.0,
|
||||
) -> dict:
|
||||
"""Single-turn запрос к ветке для регрессии (Спринт 8b).
|
||||
|
||||
Изолированно от обычного `send_message`: без роутера, без треда, без
|
||||
state machine. Просто берём активный промпт ветки + RAG-чанки по
|
||||
подпискам + LLM. Возвращаем `{answer_text, retrieved, branch_config_id,
|
||||
branch_config_version, retrieved_sections}`.
|
||||
"""
|
||||
pair = await config_service.get_active_config_by_intent_code(session, intent_code)
|
||||
if pair is None:
|
||||
raise RuntimeError(f"No active config for intent {intent_code!r}")
|
||||
intent, active_cfg = pair
|
||||
|
||||
subscribed_document_ids = await intent_document_service.list_documents_for_intent_code(
|
||||
session, intent_code,
|
||||
)
|
||||
retrieved = vectorstore.query(
|
||||
query_text=text,
|
||||
top_k=top_k,
|
||||
document_ids=subscribed_document_ids,
|
||||
)
|
||||
base_prompt = config_service.compose_full_system_prompt(active_cfg)
|
||||
|
||||
llm_result = await llm.chat(
|
||||
question=text,
|
||||
sources=retrieved,
|
||||
history=[],
|
||||
system_prompt=base_prompt,
|
||||
temperature=temperature,
|
||||
)
|
||||
parsed = parse_branch_response(llm_result["text"])
|
||||
answer_text = parsed["visible_text"] or llm_result["text"]
|
||||
|
||||
retrieved_sections = []
|
||||
for r in retrieved or []:
|
||||
meta = r.get("metadata") or {}
|
||||
section = meta.get("section") or ""
|
||||
document_name = meta.get("document_name") or ""
|
||||
retrieved_sections.append({"section": section, "document_name": document_name})
|
||||
|
||||
return {
|
||||
"answer_text": answer_text,
|
||||
"retrieved": retrieved or [],
|
||||
"retrieved_sections": retrieved_sections,
|
||||
"branch_config_id": active_cfg.id,
|
||||
"branch_config_version": active_cfg.version,
|
||||
}
|
||||
|
||||
|
||||
async def send_message(
|
||||
session: AsyncSession,
|
||||
vectorstore: VectorStoreService,
|
||||
|
||||
Reference in New Issue
Block a user