All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m41s
CI / backend-tests (push) Successful in 6m24s
CI / backend-tests (pull_request) Successful in 6m23s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m12s
Add the LLM prose-composition path for the parcel-forecast chat, layered over the deterministic Step-1 fallback which stays the safety net. - chat/tools.py: 5 read-only section tools (exec_summary, product_recommendation, forecast, risks, scenarios) — pure slices of the loaded report dict, no DB/ recompute, graceful on missing sections. market_now (raw analyze blob) and meta are deliberately NOT exposed -> highest-PII data cannot reach the LLM. - chat/safe_payload.py: the §19 gate — single place that builds the outbound SafePayload from a section-aggregate allowlist; honors is_confidential hard-block. - chat/orchestrator.py: manual tool-call loop with call-cap/termination, real grounded_in provenance; any LLMResult.ok=False (disabled/timeout/rate_limited/ redaction_refused/call_cap/provider_error/empty) degrades to the deterministic answer. - llm/prompts.py: versioned chat_system@v1 — answer only from sections, never fabricate numbers, advisory tone, decline out-of-scope. - api/v1/chat.py: branch on settings.llm_enabled; sync complete bridged via run_in_threadpool. Default-off -> deterministic path, no provider built. - Tests: fake provider only (no network), planted-secret redaction-boundary + per-reason fallback + call-cap + numbers-from-report coverage. Refs #957
151 lines
5.9 KiB
Python
151 lines
5.9 KiB
Python
"""Тесты секционных tool'ов чата (#957, Step 2) — PURE срезы report_dict, без БД/recompute.
|
||
|
||
Покрываем:
|
||
• tool_specs: 5 спек, валидная OpenAI-форма, без параметров.
|
||
• executors: достают нужную секцию ВЕРБАТИМ; числа не пересчитываются.
|
||
• robust: отсутствует секция → маркер «недоступно», НЕ KeyError.
|
||
• execute_tool/sections_for_tool: реестр имя→executor + provenance-секции.
|
||
• неизвестное имя tool'а: безопасный маркер + пустые секции (не падаем).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from app.services.chat import tools
|
||
|
||
_EXPECTED_TOOLS = {
|
||
"get_exec_summary",
|
||
"get_product_recommendation",
|
||
"get_forecast",
|
||
"get_risks",
|
||
"get_scenarios",
|
||
}
|
||
|
||
|
||
def _report() -> dict[str, Any]:
|
||
"""Узнаваемый отчёт (форма SiteFinderReport.as_dict())."""
|
||
return {
|
||
"exec_summary": {
|
||
"headline": "Комфорт-класс",
|
||
"key_numbers": {"цена_руб_м2": 250000},
|
||
"overall_confidence": "medium",
|
||
},
|
||
"future_market": {
|
||
"forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}],
|
||
"future_supply": {"pressure": 0.4},
|
||
},
|
||
"product_tz": {"obj_class": "комфорт", "mix": [{"fmt": "2k"}]},
|
||
"scoring": {"special_indices": {"indices": {"cannibalization": {"value": 0.31}}}},
|
||
"confidence": {"level": "medium", "factors": {"deals": "ok"}},
|
||
"scenarios": {"by_scenario": {"base": {}, "conservative": {}}},
|
||
}
|
||
|
||
|
||
# ── Спеки ───────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_tool_specs_cover_all_five() -> None:
|
||
specs = tools.tool_specs()
|
||
assert len(specs) == 5
|
||
assert {s["function"]["name"] for s in specs} == _EXPECTED_TOOLS
|
||
|
||
|
||
def test_tool_specs_are_valid_openai_function_shape() -> None:
|
||
for spec in tools.tool_specs():
|
||
assert spec["type"] == "function"
|
||
fn = spec["function"]
|
||
assert isinstance(fn["name"], str) and fn["name"]
|
||
assert isinstance(fn["description"], str) and fn["description"]
|
||
params = fn["parameters"]
|
||
assert params["type"] == "object"
|
||
# Секционные tool'ы без аргументов (модель не управляет вычислениями).
|
||
assert params["properties"] == {}
|
||
assert params["additionalProperties"] is False
|
||
|
||
|
||
# ── Executors: верные срезы, числа вербатим ──────────────────────────────────────
|
||
|
||
|
||
def test_exec_summary_slice_is_verbatim() -> None:
|
||
out = tools.get_exec_summary(_report())
|
||
assert out["headline"] == "Комфорт-класс"
|
||
# Число берётся как есть, не пересчитывается.
|
||
assert out["key_numbers"]["цена_руб_м2"] == 250000
|
||
|
||
|
||
def test_product_recommendation_slice() -> None:
|
||
out = tools.get_product_recommendation(_report())
|
||
assert out["obj_class"] == "комфорт"
|
||
assert out["mix"] == [{"fmt": "2k"}]
|
||
|
||
|
||
def test_forecast_slice() -> None:
|
||
out = tools.get_forecast(_report())
|
||
assert len(out["forecasts_by_horizon"]) == 2
|
||
assert out["future_supply"]["pressure"] == 0.4
|
||
|
||
|
||
def test_risks_merges_scoring_and_confidence() -> None:
|
||
out = tools.get_risks(_report())
|
||
assert set(out) == {"scoring", "confidence"}
|
||
indices = out["scoring"]["special_indices"]["indices"]
|
||
assert indices["cannibalization"]["value"] == 0.31
|
||
assert out["confidence"]["level"] == "medium"
|
||
|
||
|
||
def test_scenarios_slice() -> None:
|
||
out = tools.get_scenarios(_report())
|
||
assert set(out["by_scenario"]) == {"base", "conservative"}
|
||
|
||
|
||
# ── Robust: отсутствующая/пустая секция → маркер, НЕ KeyError ─────────────────────
|
||
|
||
|
||
def test_missing_section_returns_not_available_marker() -> None:
|
||
assert tools.get_exec_summary({}) == {"available": False}
|
||
assert tools.get_forecast({}) == {"available": False}
|
||
assert tools.get_scenarios({}) == {"available": False}
|
||
|
||
|
||
def test_risks_robust_to_missing_subsections() -> None:
|
||
out = tools.get_risks({})
|
||
assert out == {
|
||
"scoring": {"available": False},
|
||
"confidence": {"available": False},
|
||
}
|
||
|
||
|
||
def test_empty_section_dict_treated_as_unavailable() -> None:
|
||
# Пустой dict секции → маркер (нечего показывать), а не «{}» как валидные данные.
|
||
assert tools.get_product_recommendation({"product_tz": {}}) == {"available": False}
|
||
|
||
|
||
# ── Реестр: execute_tool / sections_for_tool / is_known_tool ─────────────────────
|
||
|
||
|
||
def test_execute_tool_dispatches_by_name() -> None:
|
||
out = tools.execute_tool("get_exec_summary", _report())
|
||
assert out["headline"] == "Комфорт-класс"
|
||
|
||
|
||
def test_execute_unknown_tool_returns_marker_not_raise() -> None:
|
||
assert tools.execute_tool("get_nonexistent", _report()) == {"available": False}
|
||
|
||
|
||
def test_sections_for_tool_provenance() -> None:
|
||
assert tools.sections_for_tool("get_exec_summary") == ("exec_summary",)
|
||
assert tools.sections_for_tool("get_risks") == ("scoring", "confidence")
|
||
assert tools.sections_for_tool("get_nonexistent") == ()
|
||
|
||
|
||
def test_is_known_tool() -> None:
|
||
assert tools.is_known_tool("get_forecast") is True
|
||
assert tools.is_known_tool("nope") is False
|
||
|
||
|
||
def test_executors_do_not_mutate_report() -> None:
|
||
report = _report()
|
||
before = dict(report["exec_summary"])
|
||
tools.get_exec_summary(report)
|
||
assert report["exec_summary"] == before
|