From cd0f8b8da65cbc9fc50215b80745d448ff6a6492 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Sat, 4 Jul 2026 22:27:55 +0200
Subject: [PATCH 01/27] docs(ai-search): spec projektowy wyszukiwania przez AI
NL (polski) -> Claude (SDK anthropic, Sonnet 5) -> DjangoQL -> redirect na
istniejace 'szukaj zapytaniem'. Nowa apka src/ai_search/, budzety w PLN
(NBP FX), log kosztu, walidacja+bounded retry z konkretnym bledem DjangoQL.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../specs/2026-07-04-ai-search-design.md | 346 ++++++++++++++++++
1 file changed, 346 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-04-ai-search-design.md
diff --git a/docs/superpowers/specs/2026-07-04-ai-search-design.md b/docs/superpowers/specs/2026-07-04-ai-search-design.md
new file mode 100644
index 000000000..c43914d03
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-04-ai-search-design.md
@@ -0,0 +1,346 @@
+# Wyszukiwanie „przez sztuczną inteligencję" — spec projektowy
+
+Data: 2026-07-04
+Status: zaakceptowany do implementacji (spec, po review Fable)
+
+## Cel
+
+Umożliwić odpytywanie bazy BPP zapytaniami w języku naturalnym (polskim).
+Użytkownik wpisuje pytanie po polsku, model Claude tłumaczy je na zapytanie
+DjangoQL, a wynik jest wykonywany i renderowany **tą samą maszynerią co
+istniejące „szukaj zapytaniem"** (przez redirect).
+
+Nowa pozycja w menu top-bar „szukaj": obecnie `precyzyjnie`, `szybko`,
+`zapytaniem` → dochodzi **„przez sztuczną inteligencję"**.
+
+## Zasada naczelna — front-end do istniejącego „zapytaniem"
+
+To NIE jest nowy silnik zapytań. Ścieżka:
+
+```
+pytanie PL ──> Claude (SDK anthropic, Sonnet 5) ──> {"query": ..., "error": ...}
+ │
+ walidacja DjangoQL (apply_search build)
+ │
+ query poprawny ──> redirect: bpp:zapytanie?model=...&query=
+ (render/paginacja/highlight = istniejący ZapytanieView.get)
+ query BŁĘDNY składniowo ──> ZWRÓĆ do modelu z KONKRETNYM błędem:
+ „zapytanie zwróciło błąd DjangoQL:
+ w linii L, kolumnie C; skoryguj" ──> retry (bounded)
+ query = null ──> pokaż error (pytanie niewyrażalne w DSL)
+```
+
+Konsekwencje tej zasady:
+
+- **Bezpieczeństwo jest już rozwiązane.** DjangoQL `apply_search` robi wyłącznie
+ filtrowanie read-only queryset-u. `Rekord` jest `managed = False`
+ (widok/cache `bpp_rekord_mat`); `Autor` filtrujemy tylko po stronie odczytu.
+ Wygenerowany string jest walidowany parserem DjangoQL **przed** redirectem.
+ Najgorszy przypadek prompt-injection to drogie `count()`/`distinct()` na
+ `bpp_rekord_mat` — a to jest już osiągalne z ręcznego „zapytaniem", więc
+ **nie dokładamy nowej powierzchni ataku**. Pętla retry echu-je zepsuty query
+ + błąd z powrotem do modelu — nieszkodliwe (najwyżej zmarnowane próby).
+- **Reuse renderu przez redirect** (nie refaktor). `ZapytanieView.get()`
+ (`src/bpp/views/zapytanie.py:401`) już renderuje wyniki z parametrów GET.
+ Widok AI po udanym tłumaczeniu **przekierowuje na
+ `bpp:zapytanie?model=...&query=...`** i dostaje render, paginację,
+ `_attach_admin_urls`, highlight błędów oraz **edytowalny wygenerowany query**
+ (escape hatch) za darmo, zero ryzyka refaktoru. Oryginalne pytanie PL
+ przekazywane przez session flash (opcjonalnie mały baner w `zapytanie.html`).
+- **Audytorium i schemat identyczne** jak „zapytaniem" — ten sam gate dostępu,
+ ta sama `BppQLSchema`, te same modele (Rekord, Autor).
+
+## Zakres iteracji 1
+
+- Oba modele: **Rekord** i **Autor** (jak „zapytaniem", radio wyboru).
+- Dostęp jak „zapytaniem": superuser **lub** staff w grupie
+ „wprowadzanie danych" (`WprowadzanieDanychOrSuperuserMixin` /
+ `user_can_use_query_editor`, `src/bpp/views/zapytanie.py:274-294`).
+- Model: **`claude-sonnet-5`** przez oficjalny SDK `anthropic` (konfigurowalny).
+- Kontrola kosztów: **budżety w PLN** (dzienny + miesięczny), twardy blok po
+ przekroczeniu. Brak liczników liczby zapytań.
+- Logowanie każdego zapytania wraz z kosztem (USD + PLN).
+
+## Klient LLM — oficjalny SDK `anthropic` (nie LiteLLM)
+
+Decyzja po review: **oficjalny SDK `anthropic`**, nie LiteLLM.
+
+- Powód: koszt i tak musimy liczyć z `usage` × cennik (LiteLLM `response_cost`
+ cicho zwraca `None`/`0` dla świeżego modelu spoza jego bundled cennika — to
+ defeat-owało cały mechanizm budżetu). Skoro własny cennik jest konieczny,
+ SDK `anthropic` daje resztę taniej: typowane błędy, natywny `cache_control`,
+ natywne structured outputs (`messages.parse` / `output_config.format`),
+ brak ciężkich zależności (LiteLLM ciągnie openai/tiktoken/aiohttp…).
+- **Dodać `anthropic` do `pyproject.toml`** (+ `uv lock`).
+- SDK czyta `ANTHROPIC_API_KEY` z env.
+
+## Architektura — nowa apka `src/ai_search/`
+
+Osobna apka w `INSTALLED_APPS`, własne migracje — izolacja od przeciążonego
+`bpp`, czyste granice, testowalność w oderwaniu.
+
+```
+src/ai_search/
+ __init__.py apps.py
+ models.py # AISearchQuery (log + koszt), FxRate (trwały fallback kursu)
+ schema_export.py # cache opisu schematu dla LLM (Redis + regen)
+ translator.py # SDK anthropic (NL→DSL) + walidacja + bounded retry
+ pricing.py # cennik modeli (Decimal) + liczenie cost_usd z usage
+ fx.py # kurs USD→PLN z NBP (cache + trwały fallback)
+ budget.py # guard budżetowy (dzienny/miesięczny PLN)
+ prompts.py # reguły twarde + few-shot PL→DSL (Rekord + Autor)
+ views.py urls.py # ZapytanieAIView; URL bpp:zapytanie-ai
+ templates/ai_search/…
+ migrations/ tests/
+```
+
+### 1. Opis schematu dla LLM (`schema_export.py`)
+
+`djangoql-iplweb` **0.28.0** ma to gotowe:
+`djangoql.llm.describe_schema_for_llm(schema)` + polecenie
+`djangoql_describe_schema_for_llm`. Zwraca JSON:
+`{start_model, grammar{…,negation}, models{pole:{type,nullable,operators,
+relates_to/note,example,suggested_values≤20}}, examples}`.
+
+Wołamy `describe_schema_for_llm(BppQLSchema(Rekord))` i `(Autor)`
+(`BppQLSchema` z `src/bpp/djangoql_schema.py:232`; instancjonowana modelem —
+`.models` to lazy BFS, jak w `ZapytanieIntrospectView`).
+
+- **Bump `djangoql-iplweb>=0.28.0`** (obecnie zainstalowane 0.27.2, pin
+ `>=0.27.2` — 0.27.2 nie ma `management/` ani `llm.py`). `uv lock`.
+- **⚠ Zmierzyć rozmiar JSON-a PRZED implementacją** (KRYTYCZNE z review):
+ BFS Rekordu ciągnie każdą powiązaną tabelę + `__rel` picker per FK
+ (`RelPickerSchemaMixin`) + do 20 `suggested_values`, a labelki pickerów
+ używają `opis_bibliograficzny_cache` (~200-znakowe cytaty). Realnie może to
+ być dziesiątki tysięcy tokenów → $0,05–0,20 input/wywołanie × (1+retry).
+ ```
+ uv run python src/manage.py djangoql_describe_schema_for_llm bpp.Rekord \
+ --schema bpp.djangoql_schema.BppQLSchema | (policz tokeny)
+ ```
+ Jeśli > ~30k tokenów → **strategia przycinania**: usunąć `suggested_values`
+ dla pickerów, ograniczyć głębokość BFS, albo wykluczyć pola z ciężkimi
+ labelkami. Do rozstrzygnięcia po pomiarze, w planie.
+- **⚠ Dane do Anthropic:** `suggested_values` wysyłają **realne dane z bazy**
+ (nazwiska autorów, nazwy jednostek, tytuły źródeł). Dla środowiska
+ multi-hosted to decyzja o przetwarzaniu danych — udokumentować; rozważyć
+ wyłączanie `suggested_values` dla wrażliwych pickerów.
+- `_field_options` w `llm.py` połyka wszystkie wyjątki (`except Exception:
+ return None`) → zepsuty picker cicho znika ze schematu. **Logować** w
+ `schema_export.py` przy generacji, żeby to wychwycić.
+- **Cache w Redis, TTL 24h** (`BPP_AI_SCHEMA_CACHE_TTL`). Regeneracja przez
+ Celery beat i/lub management command. Stabilny (byte-identyczny) blok
+ schematu = warunek konieczny prompt cachingu (ale patrz §pkt 4 o TTL 5 min).
+
+### 2. Translator (`translator.py`)
+
+Wywołanie SDK `anthropic` (`client.messages.parse`):
+
+- **model** = `settings.BPP_AI_MODEL` (default `claude-sonnet-5`).
+- **system** (blok cache'owalny, `cache_control: {"type": "ephemeral"}`):
+ schema JSON (Rekord albo Autor) + reguły twarde z `prompts.py` (stringi w
+ cudzysłowach, relacje kropką, listy `x in (...)`, negacja operatorem
+ `!=`/`!~`/`not in`/`not startswith`/`not endswith`, brak samodzielnego `not`)
+ + 10–15 few-shot PL→DSL swoistych dla BPP (rok, `autor.nazwisko ~`,
+ `jednostka`, `charakter`, `typ_kbn`, listy, negacje, relacje 2 poziomy).
+ **Pytanie użytkownika idzie PO bloku cache'owalnym** (osobny, nie-cache'owany).
+- **`thinking={"type": "disabled"}`** — Sonnet 5 domyślnie włącza adaptive gdy
+ pole pominięte; NL→DSL nie potrzebuje myślenia; walidator+retry to siatka;
+ koszt przewidywalny. (Sonnet 5 akceptuje explicit `disabled`.)
+- **`output_format`** = pydantic `DSLQuery{query: str|None, error: str|None}`
+ (`extra="forbid"`) — natywne structured outputs Sonnet 5, `.parsed_output`.
+- **`max_tokens` twardo małe** (~500 — DjangoQL query jest krótki; cap chroni
+ koszt i latencję). Bez streamingu (mały output, < 16k).
+- **NIE ustawiać `temperature`/`top_p`/`top_k`** — Sonnet 5 odrzuca
+ niedefaultowe wartości (400).
+- **`timeout`** na kliencie (~30 s) — nie wisieć w workerze bez końca.
+- Obsługa `stop_reason == "refusal"` (mało prawdopodobne dla NL→DSL, ale
+ sprawdzić przed czytaniem `parsed_output`).
+
+#### Pętla walidacja → korekta (kluczowe)
+
+Po odpowiedzi modelu, jeśli `query` nie jest `None`:
+
+1. **Waliduj składniowo** — zbuduj przez `apply_search(qs, query,
+ schema=BppZapytanieSchema)` (ten sam wywół co „zapytaniem"). Łap komplet:
+ `DjangoQLError, FieldError, ValidationError, ValueError` (`zapytanie.py:428`).
+2. **Przechodzi** → redirect na `bpp:zapytanie` (patrz §6).
+3. **Nie przechodzi** → **zwróć błąd do modelu z konkretem**: wygenerowane
+ zapytanie `` + dokładny komunikat (`str(exc)`) + **lokalizacja**
+ `{line, column}` z `_error_location` (`zapytanie.py:356`). Treść retry:
+ *„Poprzednie zapytanie `` zwróciło błąd DjangoQL: ``
+ (linia L, kolumna C). Skoryguj i zwróć poprawne zapytanie."*
+4. **Pętla ograniczona** — `BPP_AI_MAX_RETRIES` (default 1, cap 2). Każdy retry
+ to kolejne płatne wywołanie, **liczy się do budżetu PLN** i jest sprawdzane
+ guardem przed wysłaniem. Po wyczerpaniu prób — pokaż użytkownikowi ostatni
+ błąd + wygenerowany query (do ręcznej poprawki w „zapytaniem").
+
+Jeśli `query is None` — pokaż `error` z modelu (niewyrażalne w DSL), bez akcji.
+
+- **`_error_location` przenieść do wspólnego modułu** (np.
+ `src/bpp/djangoql_helpers.py`) i importować z obu miejsc — zamiast reach-in do
+ prywatnej metody `bpp.views.zapytanie` z apki `ai_search`.
+- Translator zwraca: `query`, `error`, listę prób (`attempts`), zbiorczy
+ `usage` (in/out/cache tokens).
+
+### 3. Koszt z `usage` (`pricing.py`)
+
+SDK `anthropic` NIE daje gotowego kosztu — liczymy sami (to i tak było konieczne):
+
+- Cennik w settings (Decimal, per MTok), z obsługą intro-pricingu:
+ Sonnet 5 input $3 / output $15 (intro $2 / $10 **do 2026-08-31**),
+ cache read ~0,1× input, cache write ~1,25× input.
+- `cost_usd = (input_tokens×in + output_tokens×out + cache_read×in×0.1
+ + cache_write×in×1.25) / 1e6` (Decimal).
+- **Traktować `usage` puste / zerowe przy niezerowej treści jako błąd** (log +
+ Rollbar) — nie logować cicho 0,00.
+
+### 4. Prompt caching — realistyczne oczekiwania
+
+Z review (KRYTYCZNE #2): TTL cache Anthropic to **5 min** (ephemeral). Pojedynczy
+staffer pytający sporadycznie prawie zawsze zapłaci **write** (1,25×), nie
+**read** (0,1×). Redis 24h trzyma prefix byte-stabilny (konieczne, nie
+wystarczające). **Retry w pętli walidacji trafią w cache** (są < 5 min) — realny
+zysk. Wnioski:
+
+- Jeden `cache_control` breakpoint na końcu bloku schema+reguły; pytanie po nim.
+- **Budżet `BPP_AI_DAILY_BUDGET_PLN` wyceniać przy ~1,25× niecache'owanego
+ schematu na pytanie**, nie 0,1×.
+- Min. cache'owalny prefix Sonnet 5 nie jest w opublikowanej tabeli (Sonnet 4.6/
+ Fable 5 = 2048, Opus 4.8 = 4096). Zakładać ≥2048 i **zweryfikować
+ `usage.cache_read_input_tokens > 0`** empirycznie.
+
+### 5. Kurs USD→PLN (`fx.py`)
+
+- Źródło: **NBP** — `https://api.nbp.pl/api/exchangerates/rates/A/USD/?format=json`
+ → `rates[0].mid` (HTTPS!).
+- **Cache Redis** (`BPP_AI_FX_CACHE_TTL`, default 24h). Tabela A tylko w dni
+ robocze.
+- **Trwały fallback w DB** (`FxRate` — wiersz z ostatnim znanym kursem +
+ timestamp), nie tylko Redis: gdy Redis pusty **i** NBP down. Terminalny
+ fallback (nigdy nie było zapisu): konserwatywny stały kurs z settings
+ (`BPP_AI_FX_FALLBACK`, np. 4.5) + log. **Nigdy nie blokujemy feature'a z
+ powodu FX** — do wyceny wystarczy ostatni znany/konserwatywny kurs.
+
+### 6. Log + koszt (`models.py` → `AISearchQuery`)
+
+Pola: `user` (FK, nullable), `created` (auto, **`db_index=True`**),
+`model`, `pytanie`, `wygenerowany_query`, `wybrany_model_danych`,
+`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
+`cost_usd` (Decimal), `fx_rate` (Decimal), `cost_pln` (Decimal),
+`success` (bool), `error` (tekst, nullable), `retried` (bool).
+
+Źródło prawdy dla budżetów (agregacja po `created` + `cost_pln`).
+
+### 7. Guard budżetowy (`budget.py`)
+
+- Przed **każdym** wywołaniem (także przed każdym retry): suma `cost_pln` z
+ **dziś** i z **bieżącego miesiąca** (agregacja `AISearchQuery`; opcjonalnie
+ licznik Redis jako cache, DB źródłem prawdy). **Strefa czasowa**: Django
+ `TIME_ZONE` (nie UTC) dla granic „dziś/miesiąc".
+- Porównaj z env `BPP_AI_DAILY_BUDGET_PLN` / `BPP_AI_MONTHLY_BUDGET_PLN`.
+- Po przekroczeniu — **twardy blok**: komunikat „limit na dziś/miesiąc
+ osiągnięty, spróbuj później lub użyj «szukaj zapytaniem»" + link do
+ `bpp:zapytanie`. Zero wywołań API.
+- **Brak liczników per-user/globalnych** — jedyny mechanizm to budżety PLN.
+- **Race / „miękki na ostatnim zapytaniu"** (świadomie akceptowane): koszt
+ znamy po wywołaniu; N równoległych żądań może przekroczyć próg o
+ (N−1)×koszt. Przy zaufanym staffie i koszcie rzędu groszy–złotych — OK.
+ Twardy `max_tokens` (§2) ogranicza koszt pojedynczego wywołania.
+
+### 8. Widok i UX (`views.py`) — redirect
+
+- URL `bpp:zapytanie-ai`, dostęp jak `zapytanie`
+ (`WprowadzanieDanychOrSuperuserMixin`).
+- Formularz: radio (Rekord/Autor) + textarea na pytanie PL.
+- Submit (POST): guard budżetu → translator (z pętlą walidacji) →
+ - sukces: **redirect na `bpp:zapytanie?model=&query=`**; pytanie PL
+ w session flash (opcjonalny baner w `zapytanie.html`: „wygenerowano z: …").
+ - błąd składniowy po wyczerpaniu retry / `query=null` / blok budżetu /
+ błąd API: render strony AI z komunikatem (+ wygenerowany query jeśli jest).
+- **Latencja (KRYTYCZNE UX):** wywołanie 3–15 s trzyma workera WSGI. Minimum:
+ JS blokujący przycisk submit + stan „tłumaczę pytanie…", `timeout` ~30 s w
+ SDK, przyjazny komunikat.
+- **Obsługa błędów API** (`anthropic.AuthenticationError`,
+ `RateLimitError`, `APITimeoutError`, `APIConnectionError`, `APIStatusError`):
+ log przez `rollbar.report_exc_info()` (zgodnie z CLAUDE.md) + generyczny
+ komunikat. Osobno przypadek **„klucz jest, ale odrzucony w runtime"**
+ (`AuthenticationError`) — nie tylko „brak klucza".
+
+### 9. Menu top-bar
+
+`src/django_bpp/templates/top_bar.html` (dropdown „szukaj", ~18–43): dodać
+**„przez sztuczną inteligencję"**. Gate przez **istniejący filtr** (single
+source of truth, nie kopia surowego warunku):
+
+```django
+{% load query_editor %}
+{% if request.user|can_use_query_editor and BPP_AI_SEARCH_ENABLED %}
+ …pozycja menu…
+{% endif %}
+```
+
+(`can_use_query_editor` — `src/bpp/templatetags/query_editor.py:8`, opakowuje
+`user_can_use_query_editor`.) Pozycja widoczna tylko gdy feature włączony i
+klucz skonfigurowany.
+
+### 10. Konfiguracja (multi-hosted)
+
+Env/settings (defaulty; per-tenant przez env):
+
+- `BPP_AI_SEARCH_ENABLED` (default `False`),
+- `BPP_AI_MODEL` (default `claude-sonnet-5`),
+- `ANTHROPIC_API_KEY` (czyta SDK),
+- `BPP_AI_DAILY_BUDGET_PLN`, `BPP_AI_MONTHLY_BUDGET_PLN`,
+- `BPP_AI_MAX_RETRIES` (default 1, cap 2),
+- `BPP_AI_PRICING` (cennik Decimal per model; default z intro-datą),
+- `BPP_AI_FX_FALLBACK` (konserwatywny kurs terminalny, np. 4.5),
+- `BPP_AI_SCHEMA_CACHE_TTL`, `BPP_AI_FX_CACHE_TTL` (default 24h),
+- `BPP_AI_LLM_TIMEOUT` (default 30 s).
+
+Gdy wyłączone lub brak klucza: pozycja menu ukryta, widok 404/redirect, no-op.
+
+## Testy
+
+- `translator` (mock SDK): poprawne tłumaczenie; walidacja→retry z konkretnym
+ błędem (błąd → retry → sukces); `query=null`; `refusal`; **`usage`
+ zerowe/None → błąd** (KRYTYCZNE #1); brak `temperature` w wywołaniu.
+- `pricing`: liczenie `cost_usd` z usage (w tym cache read/write), intro-data.
+- `fx`: NBP OK; NBP down → Redis; Redis+NBP down → `FxRate` DB; brak wszystkiego
+ → `BPP_AI_FX_FALLBACK`.
+- `budget`: przejście; przekroczenie dzienne/miesięczne (twardy blok);
+ strefa czasowa granic; guard re-check przed każdym retry.
+- `schema_export`: cache hit/miss, regen, kształt JSON dla Rekord i Autor,
+ log zepsutego pickera.
+- `views`: gate dostępu; happy path → **redirect z poprawnym query w GET**;
+ blok budżetu; błąd API → Rollbar + komunikat; brak/nieważny klucz.
+- **Accuracy 30–50 par PL→DSL** — `@pytest.mark.skipif(not ANTHROPIC_API_KEY)`,
+ wykluczone z CI shardów (może wołać realny model).
+- Konwencje: pytest, `@pytest.mark.django_db`, `model_bakery.baker`, bez klas.
+
+## Migracje / baseline
+
+- Migracja dla `AISearchQuery` + `FxRate` (apka `ai_search`).
+- **`make baseline-update` przy scalaniu** (nie w gałęzi równolegle). Commit obu:
+ `baseline-sql/baseline.sql` + `baseline-sql/baseline.meta.json`.
+
+## Zależności
+
+- **Dodać `anthropic`** do `pyproject.toml` (+ `uv lock`).
+- **Bump `djangoql-iplweb>=0.28.0`** (+ `uv lock`).
+
+## Poza zakresem iteracji 1
+
+- Pre-flight oszacowanie kosztu (twardy sufit per-zapytanie przed wywołaniem).
+- Fallback na tańszy model (Haiku) po przekroczeniu budżetu.
+- Publiczny/anonimowy dostęp i limity per-IP.
+- Liczniki liczby zapytań per-user/globalne.
+- Model lokalny (Ollama/qwen) jako alternatywa on-premise.
+- Asynchroniczne wywołanie (Celery) zamiast blokowania workera — jeśli latencja
+ okaże się problemem, kandydat na iterację 2.
+
+## Otwarte punkty do potwierdzenia w planie
+
+- Nazwa URL/namespace (`bpp:zapytanie-ai`).
+- Wynik pomiaru rozmiaru JSON-a schematu → decyzja o przycinaniu.
+- Dokładny kształt banera z pytaniem PL w `zapytanie.html` (albo tylko flash).
+- Min. cache'owalny prefix Sonnet 5 — weryfikacja empiryczna.
From b14221c8d5720948771296dd358bb72b941a21e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Sat, 4 Jul 2026 22:34:55 +0200
Subject: [PATCH 02/27] docs(ai-search): plan implementacji (14 taskow, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../superpowers/plans/2026-07-04-ai-search.md | 1933 +++++++++++++++++
1 file changed, 1933 insertions(+)
create mode 100644 docs/superpowers/plans/2026-07-04-ai-search.md
diff --git a/docs/superpowers/plans/2026-07-04-ai-search.md b/docs/superpowers/plans/2026-07-04-ai-search.md
new file mode 100644
index 000000000..11193bdb9
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-04-ai-search.md
@@ -0,0 +1,1933 @@
+# Wyszukiwanie „przez sztuczną inteligencję" — plan implementacji
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Nowy tryb wyszukiwania — użytkownik pisze pytanie po polsku, Claude tłumaczy je na DjangoQL, po walidacji następuje redirect na istniejące „szukaj zapytaniem"; koszt logowany i ograniczony budżetami PLN.
+
+**Architecture:** Nowa apka `src/ai_search/` (izolowana, własny namespace URL `ai_search`). NL→DSL przez oficjalny SDK `anthropic` (`messages.parse`, structured outputs, `thinking` wyłączone). Walidacja przez `apply_search` + bounded retry z konkretnym błędem DjangoQL. Render przez redirect na `bpp:zapytanie?model=&query=`. Koszt liczony z `usage` × cennik (Decimal), przeliczany USD→PLN kursem NBP (cache + trwały fallback), budżety dzienny/miesięczny jako twardy blok.
+
+**Tech Stack:** Django, pytest + model_bakery, `anthropic` SDK, `djangoql-iplweb>=0.28.0`, `requests` (NBP), Django cache (Redis), django-environ (settings).
+
+## Global Constraints
+
+- **Max linia 88 znaków** (ruff).
+- **NIE modyfikować istniejących migracji** w `src/*/migrations/`.
+- **Wszystkie polecenia Pythona przez `uv run`.**
+- **Zakaz `except: pass` / `except Exception: pass`** — logować (Rollbar dla tła), re-raise albo zwracać sensowny błąd.
+- **Testy: pytest, funkcje bez klas, `@pytest.mark.django_db`, `model_bakery.baker`.**
+- **Model:** `claude-sonnet-5` (SDK `anthropic`; NIE ustawiać `temperature`/`top_p`/`top_k` — Sonnet 5 je odrzuca; `thinking={"type":"disabled"}`).
+- **Dostęp:** superuser LUB staff w grupie „wprowadzanie danych" (`user_can_use_query_editor`).
+- **Kontrola kosztów:** wyłącznie budżety PLN (dzienny + miesięczny), twardy blok. Brak liczników liczby zapytań.
+- **Baseline:** `make baseline-update` dopiero przy scalaniu (nie w gałęzi).
+- Ikony w adminie/templatkach publicznych: publiczny frontend Foundation Icons (``).
+
+**Reużywane, istniejące punkty (nie zmieniać semantyki):**
+- `bpp.views.zapytanie.user_can_use_query_editor` (`zapytanie.py:274`), `WprowadzanieDanychOrSuperuserMixin` (`:287`).
+- `bpp.djangoql_schema.BppQLSchema` (`:232`), alias `BppZapytanieSchema` (`zapytanie.py:26`).
+- `MODELS = {"rekord": Rekord, "autor": Autor}` (`zapytanie.py:36`), `MODEL_CHOICES` (`:31`).
+- `_error_location(exc, query)` (`zapytanie.py:356`), `_format_error_text` (`:333`) — przenoszone w Tasku 5.
+- Reverse istniejącego widoku: `bpp:zapytanie`.
+- Filtr szablonowy `can_use_query_editor` (`bpp/templatetags/query_editor.py:8`).
+
+---
+
+## Struktura plików
+
+```
+src/ai_search/
+ __init__.py
+ apps.py # AiSearchConfig (name="ai_search")
+ models.py # AISearchQuery, FxRate
+ migrations/0001_initial.py
+ pricing.py # cost_usd_from_usage(usage, model, at_date) -> Decimal
+ fx.py # usd_to_pln_rate() -> Decimal (NBP + cache + FxRate + fallback)
+ schema_export.py # schema_for_llm(model_key) -> dict (Redis cache)
+ prompts.py # HARD_RULES, FEW_SHOT, build_system(schema_dict, model_key)
+ translator.py # translate(pytanie, model_key) -> TranslationResult
+ budget.py # check_budget() -> BudgetStatus ; spent_today/month
+ forms.py # AISearchForm
+ views.py # ZapytanieAIView
+ urls.py # app_name="ai_search"; path("", index, name="index")
+ templates/ai_search/zapytanie_ai.html
+ management/commands/ai_search_schema_dump.py # pomiar rozmiaru + ręczny podgląd
+ tests/
+ __init__.py conftest.py
+ test_pricing.py test_fx.py test_schema_export.py
+ test_translator.py test_budget.py test_views.py
+ test_accuracy.py # skipif brak ANTHROPIC_API_KEY, wykluczony z CI
+
+src/bpp/djangoql_helpers.py # NOWY: _error_location, _format_error_text (Task 5)
+```
+
+Modyfikacje istniejących plików:
+- `pyproject.toml` — dodać `anthropic`, bump `djangoql-iplweb>=0.28.0`.
+- `src/django_bpp/settings/base.py` — INSTALLED_APPS += `ai_search`; sekcja settings `BPP_AI_*`.
+- `src/django_bpp/urls.py` — include `ai_search.urls`.
+- `src/bpp/views/zapytanie.py` — importować `_error_location`/`_format_error_text` z `bpp.djangoql_helpers` (re-export dla zgodności).
+- `src/django_bpp/templates/top_bar.html` — pozycja „przez sztuczną inteligencję".
+
+---
+
+## Task 1: Scaffold apki + zależności + settings
+
+**Files:**
+- Create: `src/ai_search/__init__.py`, `src/ai_search/apps.py`, `src/ai_search/tests/__init__.py`
+- Modify: `pyproject.toml`, `src/django_bpp/settings/base.py:365` (INSTALLED_APPS), koniec `base.py` (settings BPP_AI_*)
+- Test: `src/ai_search/tests/test_app.py`
+
+**Interfaces:**
+- Produces: apka `ai_search` w INSTALLED_APPS; settingsy `BPP_AI_SEARCH_ENABLED`, `BPP_AI_MODEL`, `BPP_AI_DAILY_BUDGET_PLN`, `BPP_AI_MONTHLY_BUDGET_PLN`, `BPP_AI_MAX_RETRIES`, `BPP_AI_LLM_TIMEOUT`, `BPP_AI_SCHEMA_CACHE_TTL`, `BPP_AI_FX_CACHE_TTL`, `BPP_AI_FX_FALLBACK`, `BPP_AI_PRICING`.
+
+- [ ] **Step 1: Dodaj zależności do pyproject.toml**
+
+W `[project].dependencies` zmień pin djangoql i dodaj anthropic:
+```toml
+ "djangoql-iplweb>=0.28.0",
+ "anthropic>=0.40",
+```
+(zastępując istniejące `"djangoql-iplweb>=0.27.2",`).
+
+- [ ] **Step 2: uv lock + sync**
+
+Run: `uv lock && uv sync`
+Expected: lock zaktualizowany; `uv run python -c "import djangoql,anthropic;print(djangoql.__version__)"` → `0.28.0` (lub wyżej) bez błędu importu `anthropic`.
+
+- [ ] **Step 3: Utwórz szkielet apki**
+
+`src/ai_search/__init__.py` (pusty).
+`src/ai_search/apps.py`:
+```python
+from django.apps import AppConfig
+
+
+class AiSearchConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "ai_search"
+ verbose_name = "Wyszukiwanie przez AI"
+```
+`src/ai_search/tests/__init__.py` (pusty).
+
+- [ ] **Step 4: Zarejestruj apkę w INSTALLED_APPS**
+
+W `src/django_bpp/settings/base.py` w liście `INSTALLED_APPS` dodaj `"ai_search",` bezpośrednio po `"bpp",` (linia ~414).
+
+- [ ] **Step 5: Dodaj sekcję settings BPP_AI_* na końcu base.py**
+
+Dopisz (używając `env` z django-environ, wzór jak reszta pliku):
+```python
+# --- Wyszukiwanie przez AI (ai_search) ---
+BPP_AI_SEARCH_ENABLED = env("BPP_AI_SEARCH_ENABLED", default=False, cast=bool)
+BPP_AI_MODEL = env("BPP_AI_MODEL", default="claude-sonnet-5")
+BPP_AI_DAILY_BUDGET_PLN = env(
+ "BPP_AI_DAILY_BUDGET_PLN", default="20", cast=str
+)
+BPP_AI_MONTHLY_BUDGET_PLN = env(
+ "BPP_AI_MONTHLY_BUDGET_PLN", default="300", cast=str
+)
+BPP_AI_MAX_RETRIES = env("BPP_AI_MAX_RETRIES", default=1, cast=int)
+BPP_AI_LLM_TIMEOUT = env("BPP_AI_LLM_TIMEOUT", default=30, cast=int)
+BPP_AI_SCHEMA_CACHE_TTL = env("BPP_AI_SCHEMA_CACHE_TTL", default=86400, cast=int)
+BPP_AI_FX_CACHE_TTL = env("BPP_AI_FX_CACHE_TTL", default=86400, cast=int)
+BPP_AI_FX_FALLBACK = env("BPP_AI_FX_FALLBACK", default="4.5", cast=str)
+# Cennik per model, USD za 1M tokenów. `intro_until` (ISO date) -> do tej daty
+# obowiązują ceny intro. cache_read/cache_write to mnożniki ceny input.
+BPP_AI_PRICING = {
+ "claude-sonnet-5": {
+ "input": "3.0",
+ "output": "15.0",
+ "intro_input": "2.0",
+ "intro_output": "10.0",
+ "intro_until": "2026-08-31",
+ "cache_read_mult": "0.1",
+ "cache_write_mult": "1.25",
+ },
+}
+```
+Uwaga: kwoty jako `str`, w kodzie konwertowane na `Decimal` (unikamy floatów w pieniądzach).
+
+- [ ] **Step 6: Test — apka i settingsy ładują się**
+
+`src/ai_search/tests/test_app.py`:
+```python
+from django.apps import apps
+from django.conf import settings
+
+
+def test_app_registered():
+ assert apps.is_installed("ai_search")
+
+
+def test_ai_settings_present():
+ assert hasattr(settings, "BPP_AI_MODEL")
+ assert settings.BPP_AI_MODEL
+ assert "claude-sonnet-5" in settings.BPP_AI_PRICING
+```
+
+- [ ] **Step 7: Uruchom test**
+
+Run: `uv run pytest src/ai_search/tests/test_app.py -v`
+Expected: PASS (2).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add pyproject.toml uv.lock src/ai_search/ src/django_bpp/settings/base.py
+git commit -m "feat(ai-search): scaffold apki + zaleznosci (anthropic, djangoql 0.28) + settings"
+```
+
+---
+
+## Task 2: Modele — AISearchQuery + FxRate + migracja
+
+**Files:**
+- Create: `src/ai_search/models.py`, `src/ai_search/migrations/__init__.py`
+- Test: `src/ai_search/tests/test_models.py`
+
+**Interfaces:**
+- Produces:
+ - `AISearchQuery` z polami: `user (FK, null)`, `created (auto, db_index)`, `model (str)`, `pytanie (Text)`, `wygenerowany_query (Text, blank)`, `wybrany_model_danych (str)`, `input_tokens/output_tokens/cache_read_tokens/cache_write_tokens (int)`, `cost_usd/fx_rate/cost_pln (Decimal)`, `success (bool)`, `error (Text, null)`, `retried (bool)`.
+ - `FxRate` z polami: `rate (Decimal)`, `fetched_at (auto)`; klasmetoda `latest()` i `store(rate)`.
+
+- [ ] **Step 1: Napisz modele**
+
+`src/ai_search/models.py`:
+```python
+from decimal import Decimal
+
+from django.conf import settings
+from django.db import models
+
+
+class AISearchQuery(models.Model):
+ """Log pojedynczego zapytania NL->DSL wraz z kosztem."""
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL
+ )
+ created = models.DateTimeField(auto_now_add=True, db_index=True)
+ model = models.CharField(max_length=100)
+ pytanie = models.TextField()
+ wygenerowany_query = models.TextField(blank=True, default="")
+ wybrany_model_danych = models.CharField(max_length=32)
+ input_tokens = models.IntegerField(default=0)
+ output_tokens = models.IntegerField(default=0)
+ cache_read_tokens = models.IntegerField(default=0)
+ cache_write_tokens = models.IntegerField(default=0)
+ cost_usd = models.DecimalField(max_digits=12, decimal_places=6, default=Decimal("0"))
+ fx_rate = models.DecimalField(max_digits=10, decimal_places=4, default=Decimal("0"))
+ cost_pln = models.DecimalField(max_digits=12, decimal_places=4, default=Decimal("0"))
+ success = models.BooleanField(default=False)
+ error = models.TextField(null=True, blank=True)
+ retried = models.BooleanField(default=False)
+
+ class Meta:
+ verbose_name = "zapytanie AI"
+ verbose_name_plural = "zapytania AI"
+ ordering = ("-created",)
+
+ def __str__(self):
+ return f"{self.created:%Y-%m-%d %H:%M} {self.pytanie[:60]}"
+
+
+class FxRate(models.Model):
+ """Trwały fallback ostatniego znanego kursu USD->PLN (gdy Redis+NBP padną)."""
+
+ rate = models.DecimalField(max_digits=10, decimal_places=4)
+ fetched_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ("-fetched_at",)
+
+ @classmethod
+ def latest(cls):
+ return cls.objects.first()
+
+ @classmethod
+ def store(cls, rate):
+ return cls.objects.create(rate=Decimal(str(rate)))
+```
+
+- [ ] **Step 2: Utwórz katalog migracji i wygeneruj migrację**
+
+```bash
+mkdir -p src/ai_search/migrations && touch src/ai_search/migrations/__init__.py
+uv run python src/manage.py makemigrations ai_search
+```
+Expected: `src/ai_search/migrations/0001_initial.py` utworzone (AISearchQuery, FxRate).
+
+- [ ] **Step 3: Napisz testy modeli**
+
+`src/ai_search/tests/test_models.py`:
+```python
+from decimal import Decimal
+
+import pytest
+from model_bakery import baker
+
+from ai_search.models import AISearchQuery, FxRate
+
+
+@pytest.mark.django_db
+def test_aisearchquery_str():
+ q = baker.make(AISearchQuery, pytanie="ksiazki po 2020")
+ assert "ksiazki po 2020" in str(q)
+
+
+@pytest.mark.django_db
+def test_fxrate_store_and_latest():
+ assert FxRate.latest() is None
+ FxRate.store("4.12")
+ FxRate.store("4.20")
+ assert FxRate.latest().rate == Decimal("4.2000")
+```
+
+- [ ] **Step 4: Uruchom testy**
+
+Run: `uv run pytest src/ai_search/tests/test_models.py -v`
+Expected: PASS (2).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ai_search/models.py src/ai_search/migrations/ src/ai_search/tests/test_models.py
+git commit -m "feat(ai-search): modele AISearchQuery + FxRate + migracja"
+```
+
+---
+
+## Task 3: Wycena — pricing.py (koszt z usage)
+
+**Files:**
+- Create: `src/ai_search/pricing.py`
+- Test: `src/ai_search/tests/test_pricing.py`
+
+**Interfaces:**
+- Consumes: `settings.BPP_AI_PRICING`.
+- Produces: `cost_usd_from_usage(usage: dict, model: str, at_date: date) -> Decimal`, gdzie `usage` = `{"input_tokens","output_tokens","cache_read_tokens","cache_write_tokens"}`.
+
+- [ ] **Step 1: Napisz test (TDD)**
+
+`src/ai_search/tests/test_pricing.py`:
+```python
+from datetime import date
+from decimal import Decimal
+
+from ai_search.pricing import cost_usd_from_usage
+
+USAGE = {
+ "input_tokens": 1_000_000,
+ "output_tokens": 100_000,
+ "cache_read_tokens": 0,
+ "cache_write_tokens": 0,
+}
+
+
+def test_intro_pricing_before_cutoff():
+ # intro: input 2.0, output 10.0 -> 2 + 1 = 3.0 USD
+ cost = cost_usd_from_usage(USAGE, "claude-sonnet-5", date(2026, 7, 1))
+ assert cost == Decimal("3.000000")
+
+
+def test_standard_pricing_after_cutoff():
+ # standard: input 3.0, output 15.0 -> 3 + 1.5 = 4.5 USD
+ cost = cost_usd_from_usage(USAGE, "claude-sonnet-5", date(2026, 9, 1))
+ assert cost == Decimal("4.500000")
+
+
+def test_cache_read_and_write_multipliers():
+ usage = {
+ "input_tokens": 0,
+ "output_tokens": 0,
+ "cache_read_tokens": 1_000_000,
+ "cache_write_tokens": 1_000_000,
+ }
+ # standard input 3.0; read 0.1x=0.3, write 1.25x=3.75 -> 4.05
+ cost = cost_usd_from_usage(usage, "claude-sonnet-5", date(2026, 9, 1))
+ assert cost == Decimal("4.050000")
+
+
+def test_unknown_model_raises():
+ import pytest
+
+ with pytest.raises(KeyError):
+ cost_usd_from_usage(USAGE, "no-such-model", date(2026, 9, 1))
+```
+
+- [ ] **Step 2: Uruchom — ma paść**
+
+Run: `uv run pytest src/ai_search/tests/test_pricing.py -v`
+Expected: FAIL (ModuleNotFoundError: ai_search.pricing).
+
+- [ ] **Step 3: Implementacja**
+
+`src/ai_search/pricing.py`:
+```python
+from datetime import date
+from decimal import Decimal
+
+from django.conf import settings
+
+_MILLION = Decimal("1000000")
+
+
+def _price(cfg: dict, key: str, at_date: date) -> Decimal:
+ """Cena input/output per 1M tokenów, z uwzględnieniem intro-pricingu."""
+ intro_until = cfg.get("intro_until")
+ if intro_until and at_date <= date.fromisoformat(intro_until):
+ return Decimal(cfg[f"intro_{key}"])
+ return Decimal(cfg[key])
+
+
+def cost_usd_from_usage(usage: dict, model: str, at_date: date) -> Decimal:
+ """Koszt (USD, Decimal) danego wywołania na podstawie usage i cennika.
+
+ ``usage`` klucze: input_tokens, output_tokens, cache_read_tokens,
+ cache_write_tokens. Podnosi KeyError dla nieznanego modelu — brak ceny to
+ błąd, nie cichy koszt zero (patrz spec, KRYT. #1).
+ """
+ cfg = settings.BPP_AI_PRICING[model]
+ in_price = _price(cfg, "input", at_date)
+ out_price = _price(cfg, "output", at_date)
+ read_mult = Decimal(cfg["cache_read_mult"])
+ write_mult = Decimal(cfg["cache_write_mult"])
+
+ total = (
+ Decimal(usage.get("input_tokens", 0)) * in_price
+ + Decimal(usage.get("output_tokens", 0)) * out_price
+ + Decimal(usage.get("cache_read_tokens", 0)) * in_price * read_mult
+ + Decimal(usage.get("cache_write_tokens", 0)) * in_price * write_mult
+ ) / _MILLION
+ return total.quantize(Decimal("0.000001"))
+```
+
+- [ ] **Step 4: Uruchom — ma przejść**
+
+Run: `uv run pytest src/ai_search/tests/test_pricing.py -v`
+Expected: PASS (4).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ai_search/pricing.py src/ai_search/tests/test_pricing.py
+git commit -m "feat(ai-search): wycena kosztu z usage (Decimal, intro-pricing, cache mult)"
+```
+
+---
+
+## Task 4: Kurs walutowy — fx.py (NBP + cache + trwały fallback)
+
+**Files:**
+- Create: `src/ai_search/fx.py`
+- Test: `src/ai_search/tests/test_fx.py`
+
+**Interfaces:**
+- Consumes: `FxRate` (Task 2), `settings.BPP_AI_FX_CACHE_TTL`, `settings.BPP_AI_FX_FALLBACK`, Django cache, `requests`.
+- Produces: `usd_to_pln_rate() -> Decimal`. Klucz cache: `"ai_search:fx:usdpln"`.
+
+- [ ] **Step 1: Napisz testy (TDD)**
+
+`src/ai_search/tests/test_fx.py`:
+```python
+from decimal import Decimal
+from unittest import mock
+
+import pytest
+from django.core.cache import cache
+
+from ai_search import fx
+from ai_search.models import FxRate
+
+
+@pytest.fixture(autouse=True)
+def _clear_cache():
+ cache.delete("ai_search:fx:usdpln")
+ yield
+ cache.delete("ai_search:fx:usdpln")
+
+
+def _nbp_response(mid):
+ m = mock.Mock()
+ m.raise_for_status = mock.Mock()
+ m.json.return_value = {"rates": [{"mid": mid}]}
+ return m
+
+
+@pytest.mark.django_db
+def test_fetches_from_nbp_and_persists():
+ with mock.patch("ai_search.fx.requests.get", return_value=_nbp_response(4.11)):
+ rate = fx.usd_to_pln_rate()
+ assert rate == Decimal("4.1100")
+ assert FxRate.latest().rate == Decimal("4.1100")
+
+
+@pytest.mark.django_db
+def test_uses_cache_without_second_http_call():
+ with mock.patch("ai_search.fx.requests.get", return_value=_nbp_response(4.11)) as g:
+ fx.usd_to_pln_rate()
+ fx.usd_to_pln_rate()
+ assert g.call_count == 1
+
+
+@pytest.mark.django_db
+def test_falls_back_to_db_when_nbp_down():
+ FxRate.store("4.05")
+ with mock.patch("ai_search.fx.requests.get", side_effect=OSError("boom")):
+ rate = fx.usd_to_pln_rate()
+ assert rate == Decimal("4.0500")
+
+
+@pytest.mark.django_db
+def test_terminal_fallback_when_nothing_available(settings):
+ settings.BPP_AI_FX_FALLBACK = "4.5"
+ with mock.patch("ai_search.fx.requests.get", side_effect=OSError("boom")):
+ rate = fx.usd_to_pln_rate()
+ assert rate == Decimal("4.5")
+```
+
+- [ ] **Step 2: Uruchom — ma paść**
+
+Run: `uv run pytest src/ai_search/tests/test_fx.py -v`
+Expected: FAIL (brak `ai_search.fx`).
+
+- [ ] **Step 3: Implementacja**
+
+`src/ai_search/fx.py`:
+```python
+import logging
+from decimal import Decimal
+
+import requests
+from django.conf import settings
+from django.core.cache import cache
+
+logger = logging.getLogger(__name__)
+
+_CACHE_KEY = "ai_search:fx:usdpln"
+_NBP_URL = "https://api.nbp.pl/api/exchangerates/rates/A/USD/?format=json"
+
+
+def _fetch_nbp() -> Decimal:
+ resp = requests.get(_NBP_URL, timeout=10)
+ resp.raise_for_status()
+ mid = resp.json()["rates"][0]["mid"]
+ return Decimal(str(mid)).quantize(Decimal("0.0001"))
+
+
+def usd_to_pln_rate() -> Decimal:
+ """Kurs USD->PLN. Kolejność: cache Redis -> NBP -> DB FxRate -> stała.
+
+ Nigdy nie podnosi wyjątku — FX nie może zablokować feature'a; do wyceny
+ wystarczy ostatni znany / konserwatywny kurs.
+ """
+ from ai_search.models import FxRate
+
+ cached = cache.get(_CACHE_KEY)
+ if cached is not None:
+ return Decimal(cached)
+
+ try:
+ rate = _fetch_nbp()
+ cache.set(_CACHE_KEY, str(rate), settings.BPP_AI_FX_CACHE_TTL)
+ FxRate.store(rate)
+ return rate
+ except (requests.RequestException, OSError, KeyError, ValueError) as exc:
+ logger.warning("NBP FX niedostępny (%s), używam fallbacku", exc)
+
+ last = FxRate.latest()
+ if last is not None:
+ return last.rate
+ return Decimal(settings.BPP_AI_FX_FALLBACK)
+```
+
+- [ ] **Step 4: Uruchom — ma przejść**
+
+Run: `uv run pytest src/ai_search/tests/test_fx.py -v`
+Expected: PASS (4).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ai_search/fx.py src/ai_search/tests/test_fx.py
+git commit -m "feat(ai-search): kurs USD->PLN z NBP (https) + cache + trwaly fallback"
+```
+
+---
+
+## Task 5: Przeniesienie helpera błędów DjangoQL do współdzielonego modułu
+
+**Files:**
+- Create: `src/bpp/djangoql_helpers.py`
+- Modify: `src/bpp/views/zapytanie.py` (import + re-export dla zgodności)
+- Test: `src/ai_search/tests/test_error_helper.py`
+
+**Interfaces:**
+- Produces: `bpp.djangoql_helpers._error_location(exc, query) -> (line, column, mark)` i `_format_error_text(exc) -> str` (przeniesione 1:1 z `zapytanie.py`). `_locate_token` również.
+- `zapytanie.py` re-eksportuje je, więc jego dotychczasowe użycia i testy działają bez zmian.
+
+- [ ] **Step 1: Utwórz współdzielony moduł (przenieś kod 1:1)**
+
+`src/bpp/djangoql_helpers.py` — przenieś **dokładnie** ciała `_format_error_text`, `_locate_token`, `_error_location`, `_error_payload` z `zapytanie.py:333-382` (razem z importami `re`, `ValidationError`):
+```python
+import re
+
+from django.core.exceptions import ValidationError
+
+
+def _format_error_text(exc):
+ if isinstance(exc, ValidationError):
+ return "; ".join(exc.messages)
+ return str(exc)
+
+
+def _locate_token(query, needle):
+ match = re.search(r"(? dict` (cache Redis, klucz `f"ai_search:schema:{model_key}"`), `regenerate(model_key) -> dict`.
+
+- [ ] **Step 1: Napisz testy (TDD)**
+
+`src/ai_search/tests/test_schema_export.py`:
+```python
+import pytest
+from django.core.cache import cache
+
+from ai_search import schema_export
+
+
+@pytest.fixture(autouse=True)
+def _clear():
+ for k in ("rekord", "autor"):
+ cache.delete(f"ai_search:schema:{k}")
+ yield
+
+
+@pytest.mark.django_db
+def test_schema_shape_rekord():
+ data = schema_export.schema_for_llm("rekord")
+ assert "grammar" in data and "models" in data
+ assert "negation" in data["grammar"]
+
+
+@pytest.mark.django_db
+def test_schema_shape_autor():
+ data = schema_export.schema_for_llm("autor")
+ assert data["models"] # niepusty słownik modeli
+
+
+@pytest.mark.django_db
+def test_cache_reused(monkeypatch):
+ calls = {"n": 0}
+ real = schema_export._build
+
+ def counting(model_key):
+ calls["n"] += 1
+ return real(model_key)
+
+ monkeypatch.setattr(schema_export, "_build", counting)
+ schema_export.schema_for_llm("rekord")
+ schema_export.schema_for_llm("rekord")
+ assert calls["n"] == 1
+
+
+@pytest.mark.django_db
+def test_unknown_model_key_raises():
+ with pytest.raises(KeyError):
+ schema_export.schema_for_llm("nieistnieje")
+```
+
+- [ ] **Step 2: Uruchom — ma paść**
+
+Run: `uv run pytest src/ai_search/tests/test_schema_export.py -v`
+Expected: FAIL (brak modułu).
+
+- [ ] **Step 3: Implementacja**
+
+`src/ai_search/schema_export.py`:
+```python
+import logging
+
+from django.conf import settings
+from django.core.cache import cache
+from djangoql.llm import describe_schema_for_llm
+
+from bpp.djangoql_schema import BppQLSchema
+from bpp.views.zapytanie import MODELS
+
+logger = logging.getLogger(__name__)
+
+
+def _cache_key(model_key: str) -> str:
+ return f"ai_search:schema:{model_key}"
+
+
+def _build(model_key: str) -> dict:
+ """Zbuduj opis schematu dla danego klucza modelu (rekord/autor).
+
+ Podnosi KeyError dla nieznanego klucza. describe_schema_for_llm sięga bazy
+ dla pól z suggest_options — dlatego wynik jest cache'owany."""
+ model = MODELS[model_key]
+ return describe_schema_for_llm(BppQLSchema(model))
+
+
+def regenerate(model_key: str) -> dict:
+ data = _build(model_key)
+ cache.set(_cache_key(model_key), data, settings.BPP_AI_SCHEMA_CACHE_TTL)
+ return data
+
+
+def schema_for_llm(model_key: str) -> dict:
+ cached = cache.get(_cache_key(model_key))
+ if cached is not None:
+ return cached
+ return regenerate(model_key)
+```
+
+- [ ] **Step 4: Uruchom — ma przejść**
+
+Run: `uv run pytest src/ai_search/tests/test_schema_export.py -v`
+Expected: PASS (4).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ai_search/schema_export.py src/ai_search/tests/test_schema_export.py
+git commit -m "feat(ai-search): eksport schematu DjangoQL dla LLM z cache Redis"
+```
+
+---
+
+## Task 7: Komenda pomiaru rozmiaru schematu (KRYT. #3 ze specu)
+
+**Files:**
+- Create: `src/ai_search/management/__init__.py`, `src/ai_search/management/commands/__init__.py`, `src/ai_search/management/commands/ai_search_schema_dump.py`
+- Test: `src/ai_search/tests/test_schema_dump_command.py`
+
+**Interfaces:**
+- Produces: `manage.py ai_search_schema_dump ` — drukuje JSON + na stderr przybliżoną liczbę znaków/tokenów (do decyzji o przycinaniu).
+
+- [ ] **Step 1: Test komendy**
+
+`src/ai_search/tests/test_schema_dump_command.py`:
+```python
+import pytest
+from django.core.management import call_command
+
+
+@pytest.mark.django_db
+def test_dump_rekord_runs(capsys):
+ call_command("ai_search_schema_dump", "rekord")
+ out = capsys.readouterr().out
+ assert '"grammar"' in out
+```
+
+- [ ] **Step 2: Uruchom — ma paść**
+
+Run: `uv run pytest src/ai_search/tests/test_schema_dump_command.py -v`
+Expected: FAIL (Unknown command).
+
+- [ ] **Step 3: Implementacja**
+
+Utwórz `__init__.py` w `management/` i `management/commands/`.
+`src/ai_search/management/commands/ai_search_schema_dump.py`:
+```python
+import json
+
+from django.core.management.base import BaseCommand, CommandError
+
+from ai_search import schema_export
+
+
+class Command(BaseCommand):
+ help = "Zrzuca opis schematu dla LLM i szacuje jego rozmiar (tokeny)."
+
+ def add_arguments(self, parser):
+ parser.add_argument("model_key", choices=["rekord", "autor"])
+
+ def handle(self, *args, **options):
+ try:
+ data = schema_export.regenerate(options["model_key"])
+ except KeyError as exc:
+ raise CommandError(f"Nieznany model: {exc}")
+ text = json.dumps(data, ensure_ascii=False, indent=2)
+ self.stdout.write(text)
+ # Zgrubne oszacowanie: ~4 znaki/token (angielski JSON).
+ chars = len(text)
+ self.stderr.write(
+ f"\n[rozmiar] {chars} znaków, ~{chars // 4} tokenów. "
+ f"Jeśli > ~30k tokenów — rozważ przycinanie suggested_values."
+ )
+```
+
+- [ ] **Step 4: Uruchom test + realny pomiar**
+
+Run: `uv run pytest src/ai_search/tests/test_schema_dump_command.py -v`
+Expected: PASS.
+Następnie (pomiar, wynik do specu/planu):
+`uv run python src/manage.py ai_search_schema_dump rekord >/dev/null`
+Odczytaj z stderr szacunek tokenów. **Jeśli > ~30k** — dopisz w tym tasku follow-up: przycięcie w `schema_export._build` (np. usuwanie `suggested_values` z pól-pickerów przez post-processing dict-a).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ai_search/management/ src/ai_search/tests/test_schema_dump_command.py
+git commit -m "feat(ai-search): komenda pomiaru rozmiaru schematu dla LLM"
+```
+
+---
+
+## Task 8: Prompty — reguły + few-shot (prompts.py)
+
+**Files:**
+- Create: `src/ai_search/prompts.py`
+- Test: `src/ai_search/tests/test_prompts.py`
+
+**Interfaces:**
+- Consumes: nic (dane statyczne + dict schematu).
+- Produces: `build_system(schema_dict: dict, model_key: str) -> list` — lista bloków system dla SDK anthropic; ostatni blok (schema+reguły+few-shot) ma `cache_control: {"type":"ephemeral"}`. Pytanie użytkownika NIE jest tu zawarte. Stałe: `HARD_RULES: str`, `FEW_SHOT: dict[str, list[tuple[str,str]]]` (klucze "rekord"/"autor").
+
+- [ ] **Step 1: Test (TDD)**
+
+`src/ai_search/tests/test_prompts.py`:
+```python
+from ai_search.prompts import FEW_SHOT, build_system
+
+
+def test_build_system_has_cache_control_on_last_block():
+ blocks = build_system({"grammar": {}, "models": {}}, "rekord")
+ assert blocks[-1]["cache_control"] == {"type": "ephemeral"}
+ assert "type" in blocks[-1] and blocks[-1]["type"] == "text"
+
+
+def test_build_system_embeds_schema_and_rules():
+ blocks = build_system({"grammar": {"x": 1}, "models": {}}, "rekord")
+ text = blocks[-1]["text"]
+ assert "grammar" in text # schemat wklejony
+ assert "not startswith" in text # reguła negacji
+
+
+def test_few_shot_covers_both_models():
+ assert FEW_SHOT["rekord"] and FEW_SHOT["autor"]
+```
+
+- [ ] **Step 2: Uruchom — ma paść**
+
+Run: `uv run pytest src/ai_search/tests/test_prompts.py -v`
+Expected: FAIL.
+
+- [ ] **Step 3: Implementacja**
+
+`src/ai_search/prompts.py`:
+```python
+import json
+
+HARD_RULES = """\
+Tłumaczysz pytania w języku polskim na zapytania DSL DjangoQL dla systemu
+bibliografii publikacji (BPP). Zwracasz WYŁĄCZNIE poprawne zapytanie w polu
+`query`, albo `query=null` i wyjaśnienie po polsku w `error`, gdy pytania nie
+da się wyrazić w tym DSL.
+
+ZASADY TWARDE:
+- Stringi zawsze w podwójnych cudzysłowach.
+- Relacje trawersujesz kropką, także wielopoziomowo
+ (np. autorzy.autor.nazwisko).
+- Listy: pole in ("a", "b").
+- NIE MA samodzielnego operatora `not`. Negujesz operatorem: !=, !~,
+ not in, not startswith, not endswith. (poprawnie: rok != 2020;
+ błędnie: not rok = 2020)
+- Łączenie warunków: `and` / `or`, grupowanie nawiasami.
+- Jeśli pytanie jest oceniające/nieostre ("najlepsze", "ciekawe") i nie da się
+ go zmapować na pola — ustaw query=null i wyjaśnij w error.
+"""
+
+FEW_SHOT = {
+ "rekord": [
+ ("publikacje z 2024 roku", "rok = 2024"),
+ ("prace po 2020 zawierające w tytule nowotwór",
+ 'rok > 2020 and tytul_oryginalny ~ "nowotwor"'),
+ ("artykuły o charakterze AC",
+ 'charakter_formalny.skrot = "AC"'),
+ ("prace autora o nazwisku Kowalski",
+ 'autorzy.autor.nazwisko ~ "Kowalski"'),
+ ("publikacje z lat 2022-2024", "rok >= 2022 and rok <= 2024"),
+ ("prace bez przypisanego źródła", "zrodlo = None"),
+ ("tytuły niezawierające słowa raport", 'tytul_oryginalny !~ "raport"'),
+ ],
+ "autor": [
+ ("autorzy o nazwisku zaczynającym się na Kow",
+ 'nazwisko startswith "Kow"'),
+ ("autorzy z imieniem Jan lub Anna",
+ 'imiona ~ "Jan" or imiona ~ "Anna"'),
+ ("autorzy z podanym ORCID", "orcid != None"),
+ ("autorki i autorzy bez pseudonimu", "pseudonim = None"),
+ ],
+}
+
+
+def _few_shot_text(model_key: str) -> str:
+ lines = ["PRZYKŁADY:"]
+ for pl, dsl in FEW_SHOT[model_key]:
+ lines.append(f'"{pl}" -> {dsl}')
+ return "\n".join(lines)
+
+
+def build_system(schema_dict: dict, model_key: str) -> list:
+ """Bloki `system` dla SDK anthropic. Ostatni (stabilny) blok — schemat +
+ reguły + few-shot — oznaczony cache_control, żeby retry i szybkie kolejne
+ zapytania czytały go z cache."""
+ schema_json = json.dumps(schema_dict, ensure_ascii=False)
+ text = (
+ f"{HARD_RULES}\n\nSCHEMAT (model {model_key}):\n{schema_json}\n\n"
+ f"{_few_shot_text(model_key)}"
+ )
+ return [{"type": "text", "text": text, "cache_control": {"type": "ephemeral"}}]
+```
+
+- [ ] **Step 4: Uruchom — ma przejść**
+
+Run: `uv run pytest src/ai_search/tests/test_prompts.py -v`
+Expected: PASS (3).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ai_search/prompts.py src/ai_search/tests/test_prompts.py
+git commit -m "feat(ai-search): system prompt (reguly + few-shot) z cache_control"
+```
+
+---
+
+## Task 9: Translator — SDK anthropic + walidacja + bounded retry
+
+**Files:**
+- Create: `src/ai_search/translator.py`
+- Test: `src/ai_search/tests/test_translator.py`
+
+**Interfaces:**
+- Consumes: `anthropic` SDK, `ai_search.prompts.build_system`, `ai_search.schema_export.schema_for_llm`, `bpp.djangoql_helpers._error_location/_format_error_text`, `djangoql.queryset.apply_search`, `bpp.djangoql_schema.BppQLSchema`, `bpp.views.zapytanie.MODELS`, `settings.BPP_AI_MODEL/BPP_AI_MAX_RETRIES/BPP_AI_LLM_TIMEOUT`.
+- Produces:
+ - dataclass `TranslationResult(query: str|None, error: str|None, usage: dict, attempts: int, retried: bool)` gdzie `usage` = zsumowane `{input_tokens,output_tokens,cache_read_tokens,cache_write_tokens}`.
+ - `translate(pytanie: str, model_key: str) -> TranslationResult`.
+ - `DSLQuery` (pydantic) `{query: Optional[str], error: Optional[str]}`.
+ - `validate_query(query: str, model_key: str) -> tuple[str|None, dict|None]` — zwraca `(None, None)` gdy OK, albo `(komunikat, {line,column,mark})`.
+
+- [ ] **Step 1: Testy (TDD, mockujemy SDK)**
+
+`src/ai_search/tests/test_translator.py`:
+```python
+from unittest import mock
+
+import pytest
+
+from ai_search import translator
+
+
+def _fake_response(query, error=None, usage=None):
+ parsed = translator.DSLQuery(query=query, error=error)
+ resp = mock.Mock()
+ resp.parsed_output = parsed
+ resp.stop_reason = "end_turn"
+ u = usage or {}
+ resp.usage = mock.Mock(
+ input_tokens=u.get("input_tokens", 10),
+ output_tokens=u.get("output_tokens", 5),
+ cache_read_input_tokens=u.get("cache_read_tokens", 0),
+ cache_creation_input_tokens=u.get("cache_write_tokens", 0),
+ )
+ return resp
+
+
+@pytest.fixture(autouse=True)
+def _schema(monkeypatch):
+ monkeypatch.setattr(
+ translator.schema_export, "schema_for_llm",
+ lambda k: {"grammar": {}, "models": {}},
+ )
+
+
+@pytest.mark.django_db
+def test_valid_query_first_try():
+ with mock.patch.object(
+ translator, "_call_model", return_value=_fake_response("rok = 2024")
+ ) as call:
+ res = translator.translate("publikacje z 2024", "rekord")
+ assert res.query == "rok = 2024"
+ assert res.error is None
+ assert res.attempts == 1
+ assert call.call_count == 1
+
+
+@pytest.mark.django_db
+def test_null_query_passthrough():
+ with mock.patch.object(
+ translator, "_call_model",
+ return_value=_fake_response(None, error="pytanie nieostre"),
+ ):
+ res = translator.translate("najlepsze prace", "rekord")
+ assert res.query is None
+ assert "nieostre" in res.error
+
+
+@pytest.mark.django_db
+def test_invalid_query_retries_then_succeeds():
+ responses = [_fake_response('rok = "x'), _fake_response("rok = 2024")]
+ with mock.patch.object(translator, "_call_model", side_effect=responses):
+ res = translator.translate("prace z 2024", "rekord")
+ assert res.query == "rok = 2024"
+ assert res.retried is True
+ assert res.attempts == 2
+
+
+@pytest.mark.django_db
+def test_invalid_after_retries_returns_error(settings):
+ settings.BPP_AI_MAX_RETRIES = 1
+ bad = _fake_response('rok = "x')
+ with mock.patch.object(translator, "_call_model", return_value=bad) as call:
+ res = translator.translate("prace z 2024", "rekord")
+ assert res.query is None
+ assert res.error # komunikat błędu DjangoQL
+ assert call.call_count == 2 # 1 + 1 retry
+```
+
+- [ ] **Step 2: Uruchom — ma paść**
+
+Run: `uv run pytest src/ai_search/tests/test_translator.py -v`
+Expected: FAIL (brak modułu).
+
+- [ ] **Step 3: Implementacja**
+
+`src/ai_search/translator.py`:
+```python
+import logging
+from dataclasses import dataclass, field
+from typing import Optional
+
+import anthropic
+from django.conf import settings
+from djangoql.exceptions import DjangoQLError
+from djangoql.queryset import apply_search
+from pydantic import BaseModel, ConfigDict
+
+from bpp.djangoql_helpers import _error_location, _format_error_text
+from bpp.djangoql_schema import BppQLSchema
+from bpp.views.zapytanie import MODELS
+from django.core.exceptions import FieldError, ValidationError
+
+from ai_search import prompts, schema_export
+
+logger = logging.getLogger(__name__)
+
+
+class DSLQuery(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ query: Optional[str]
+ error: Optional[str]
+
+
+@dataclass
+class TranslationResult:
+ query: Optional[str] = None
+ error: Optional[str] = None
+ usage: dict = field(default_factory=dict)
+ attempts: int = 0
+ retried: bool = False
+
+
+def validate_query(query: str, model_key: str):
+ """Zwraca (None, None) gdy zapytanie parsuje się poprawnie, inaczej
+ (komunikat, {line,column,mark})."""
+ model = MODELS[model_key]
+ try:
+ apply_search(model.objects.all(), query, schema=BppQLSchema)
+ return None, None
+ except (DjangoQLError, FieldError, ValidationError, ValueError) as exc:
+ line, column, mark = _error_location(exc, query)
+ loc = {"line": line, "column": column, "mark": mark} if line else None
+ return _format_error_text(exc), loc
+
+
+def _client() -> anthropic.Anthropic:
+ return anthropic.Anthropic(timeout=settings.BPP_AI_LLM_TIMEOUT)
+
+
+def _call_model(system, messages):
+ """Pojedyncze wywołanie modelu (wydzielone dla testowalności)."""
+ return _client().messages.parse(
+ model=settings.BPP_AI_MODEL,
+ max_tokens=500,
+ thinking={"type": "disabled"},
+ system=system,
+ messages=messages,
+ output_format=DSLQuery,
+ )
+
+
+def _extract_usage(resp) -> dict:
+ u = resp.usage
+ return {
+ "input_tokens": getattr(u, "input_tokens", 0) or 0,
+ "output_tokens": getattr(u, "output_tokens", 0) or 0,
+ "cache_read_tokens": getattr(u, "cache_read_input_tokens", 0) or 0,
+ "cache_write_tokens": getattr(u, "cache_creation_input_tokens", 0) or 0,
+ }
+
+
+def _accumulate(total: dict, part: dict):
+ for k, v in part.items():
+ total[k] = total.get(k, 0) + v
+
+
+def translate(pytanie: str, model_key: str) -> TranslationResult:
+ """NL (polski) -> DjangoQL. Waliduje i, przy błędzie składni, zwraca do
+ modelu konkretny komunikat DjangoQL (linia/kolumna) i ponawia — bounded."""
+ system = prompts.build_system(schema_export.schema_for_llm(model_key), model_key)
+ max_retries = settings.BPP_AI_MAX_RETRIES
+ total_usage: dict = {}
+ result = TranslationResult()
+ content = pytanie
+
+ for attempt in range(max_retries + 1):
+ result.attempts = attempt + 1
+ resp = _call_model(system, [{"role": "user", "content": content}])
+ _accumulate(total_usage, _extract_usage(resp))
+ result.usage = total_usage
+
+ if getattr(resp, "stop_reason", None) == "refusal":
+ result.query = None
+ result.error = "Model odmówił odpowiedzi na to pytanie."
+ return result
+
+ parsed = resp.parsed_output
+ if parsed.query is None:
+ result.query = None
+ result.error = parsed.error or "Nie można wyrazić pytania w DSL."
+ return result
+
+ err, loc = validate_query(parsed.query, model_key)
+ if err is None:
+ result.query = parsed.query
+ result.error = None
+ return result
+
+ # Błąd składni — przygotuj feedback do modelu i ponów (jeśli został budżet prób).
+ result.query = None
+ result.error = err
+ if attempt < max_retries:
+ result.retried = True
+ where = ""
+ if loc and loc.get("line"):
+ where = f" (linia {loc['line']}, kolumna {loc['column']})"
+ content = (
+ f"{pytanie}\n\nPoprzednie zapytanie `{parsed.query}` zwróciło błąd "
+ f"DjangoQL: {err}{where}. Skoryguj i zwróć poprawne zapytanie."
+ )
+ return result
+```
+
+- [ ] **Step 4: Uruchom — ma przejść**
+
+Run: `uv run pytest src/ai_search/tests/test_translator.py -v`
+Expected: PASS (4).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ai_search/translator.py src/ai_search/tests/test_translator.py
+git commit -m "feat(ai-search): translator NL->DjangoQL (SDK anthropic) + walidacja + bounded retry"
+```
+
+---
+
+## Task 10: Budżet — budget.py (agregacja + guard)
+
+**Files:**
+- Create: `src/ai_search/budget.py`
+- Test: `src/ai_search/tests/test_budget.py`
+
+**Interfaces:**
+- Consumes: `AISearchQuery`, `settings.BPP_AI_DAILY_BUDGET_PLN/BPP_AI_MONTHLY_BUDGET_PLN`, `django.utils.timezone`.
+- Produces:
+ - `spent_today() -> Decimal`, `spent_this_month() -> Decimal` (agregacja `cost_pln`, granice wg `TIME_ZONE`).
+ - dataclass `BudgetStatus(ok: bool, reason: str|None)`.
+ - `check_budget() -> BudgetStatus`.
+
+- [ ] **Step 1: Testy (TDD)**
+
+`src/ai_search/tests/test_budget.py`:
+```python
+from decimal import Decimal
+
+import pytest
+from model_bakery import baker
+
+from ai_search import budget
+from ai_search.models import AISearchQuery
+
+
+@pytest.mark.django_db
+def test_ok_when_under_budget(settings):
+ settings.BPP_AI_DAILY_BUDGET_PLN = "10"
+ settings.BPP_AI_MONTHLY_BUDGET_PLN = "100"
+ baker.make(AISearchQuery, cost_pln=Decimal("2"))
+ status = budget.check_budget()
+ assert status.ok is True
+
+
+@pytest.mark.django_db
+def test_blocks_when_daily_exceeded(settings):
+ settings.BPP_AI_DAILY_BUDGET_PLN = "5"
+ settings.BPP_AI_MONTHLY_BUDGET_PLN = "100"
+ baker.make(AISearchQuery, cost_pln=Decimal("6"))
+ status = budget.check_budget()
+ assert status.ok is False
+ assert "dzien" in status.reason.lower() or "dzień" in status.reason.lower()
+
+
+@pytest.mark.django_db
+def test_blocks_when_monthly_exceeded(settings):
+ settings.BPP_AI_DAILY_BUDGET_PLN = "1000"
+ settings.BPP_AI_MONTHLY_BUDGET_PLN = "5"
+ baker.make(AISearchQuery, cost_pln=Decimal("6"))
+ status = budget.check_budget()
+ assert status.ok is False
+ assert "mies" in status.reason.lower()
+```
+
+- [ ] **Step 2: Uruchom — ma paść**
+
+Run: `uv run pytest src/ai_search/tests/test_budget.py -v`
+Expected: FAIL.
+
+- [ ] **Step 3: Implementacja**
+
+`src/ai_search/budget.py`:
+```python
+from dataclasses import dataclass
+from decimal import Decimal
+from typing import Optional
+
+from django.conf import settings
+from django.db.models import Sum
+from django.utils import timezone
+
+from ai_search.models import AISearchQuery
+
+
+@dataclass
+class BudgetStatus:
+ ok: bool
+ reason: Optional[str] = None
+
+
+def _sum_since(dt) -> Decimal:
+ agg = AISearchQuery.objects.filter(created__gte=dt).aggregate(s=Sum("cost_pln"))
+ return agg["s"] or Decimal("0")
+
+
+def spent_today() -> Decimal:
+ now = timezone.localtime()
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ return _sum_since(start)
+
+
+def spent_this_month() -> Decimal:
+ now = timezone.localtime()
+ start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ return _sum_since(start)
+
+
+def check_budget() -> BudgetStatus:
+ """Twardy blok, gdy dzienny lub miesięczny budżet PLN jest wyczerpany."""
+ daily = Decimal(str(settings.BPP_AI_DAILY_BUDGET_PLN))
+ monthly = Decimal(str(settings.BPP_AI_MONTHLY_BUDGET_PLN))
+ if spent_today() >= daily:
+ return BudgetStatus(
+ ok=False,
+ reason="Dzienny limit kosztów AI został osiągnięty. "
+ "Spróbuj jutro lub użyj „szukaj zapytaniem”.",
+ )
+ if spent_this_month() >= monthly:
+ return BudgetStatus(
+ ok=False,
+ reason="Miesięczny limit kosztów AI został osiągnięty. "
+ "Użyj „szukaj zapytaniem”.",
+ )
+ return BudgetStatus(ok=True)
+```
+
+- [ ] **Step 4: Uruchom — ma przejść**
+
+Run: `uv run pytest src/ai_search/tests/test_budget.py -v`
+Expected: PASS (3).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ai_search/budget.py src/ai_search/tests/test_budget.py
+git commit -m "feat(ai-search): guard budzetowy (dzienny/miesieczny PLN, twardy blok)"
+```
+
+---
+
+## Task 11: Widok + formularz + URL + szablon (redirect flow)
+
+**Files:**
+- Create: `src/ai_search/forms.py`, `src/ai_search/views.py`, `src/ai_search/urls.py`, `src/ai_search/templates/ai_search/zapytanie_ai.html`
+- Modify: `src/django_bpp/urls.py` (include)
+- Test: `src/ai_search/tests/test_views.py`
+
+**Interfaces:**
+- Consumes: `translator.translate`, `budget.check_budget`, `pricing.cost_usd_from_usage`, `fx.usd_to_pln_rate`, `AISearchQuery`, `WprowadzanieDanychOrSuperuserMixin`, `MODEL_CHOICES`, reverse `bpp:zapytanie`.
+- Produces: URL `ai_search:index` (GET: formularz; POST: budżet→translate→log→redirect/render). Redirect: `bpp:zapytanie?model=&query=`; pytanie PL w `request.session["ai_search_last_question"]`.
+
+- [ ] **Step 1: Testy widoku (TDD, mock translatora)**
+
+`src/ai_search/tests/test_views.py`:
+```python
+from decimal import Decimal
+from unittest import mock
+
+import pytest
+from django.urls import reverse
+from model_bakery import baker
+
+from ai_search import translator
+from ai_search.models import AISearchQuery
+
+
+@pytest.fixture
+def staff_client(client, django_user_model):
+ u = django_user_model.objects.create_user(
+ username="ed", password="x", is_staff=True, is_superuser=True
+ )
+ client.force_login(u)
+ return client
+
+
+@pytest.mark.django_db
+def test_anonymous_denied(client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ r = client.get(reverse("ai_search:index"))
+ assert r.status_code in (302, 403)
+
+
+@pytest.mark.django_db
+def test_get_form_visible_for_staff(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ r = staff_client.get(reverse("ai_search:index"))
+ assert r.status_code == 200
+
+
+@pytest.mark.django_db
+def test_post_success_redirects_to_zapytanie(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ res = translator.TranslationResult(
+ query="rok = 2024", usage={"input_tokens": 10, "output_tokens": 5}, attempts=1
+ )
+ with mock.patch("ai_search.views.translator.translate", return_value=res), \
+ mock.patch("ai_search.views.fx.usd_to_pln_rate", return_value=Decimal("4.1")):
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "publikacje z 2024"},
+ )
+ assert r.status_code == 302
+ assert reverse("bpp:zapytanie") in r.url
+ assert "query=rok" in r.url.replace("%20", " ") or "rok" in r.url
+ log = AISearchQuery.objects.get()
+ assert log.success is True
+ assert log.cost_pln > 0
+
+
+@pytest.mark.django_db
+def test_post_blocked_by_budget(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_DAILY_BUDGET_PLN = "0"
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "cokolwiek"},
+ )
+ assert r.status_code == 200
+ assert b"limit" in r.content.lower()
+ assert AISearchQuery.objects.count() == 0
+
+
+@pytest.mark.django_db
+def test_post_null_query_shows_error(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ res = translator.TranslationResult(
+ query=None, error="pytanie nieostre", usage={"input_tokens": 5}, attempts=1
+ )
+ with mock.patch("ai_search.views.translator.translate", return_value=res), \
+ mock.patch("ai_search.views.fx.usd_to_pln_rate", return_value=Decimal("4.1")):
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "najlepsze prace"},
+ )
+ assert r.status_code == 200
+ assert "nieostre" in r.content.decode()
+ assert AISearchQuery.objects.get().success is False
+```
+
+- [ ] **Step 2: Uruchom — ma paść**
+
+Run: `uv run pytest src/ai_search/tests/test_views.py -v`
+Expected: FAIL (NoReverseMatch / brak modułów).
+
+- [ ] **Step 3: Formularz**
+
+`src/ai_search/forms.py`:
+```python
+from django import forms
+
+from bpp.views.zapytanie import MODEL_CHOICES, MODEL_REKORD
+
+
+class AISearchForm(forms.Form):
+ model = forms.ChoiceField(
+ choices=MODEL_CHOICES,
+ widget=forms.RadioSelect,
+ initial=MODEL_REKORD,
+ label="Model do przeszukania",
+ )
+ pytanie = forms.CharField(
+ label="Zadaj pytanie po polsku",
+ widget=forms.Textarea(
+ attrs={
+ "rows": 3,
+ "placeholder": "np. publikacje z 2024 roku autora Kowalskiego",
+ "autocomplete": "off",
+ }
+ ),
+ )
+```
+
+- [ ] **Step 4: Widok**
+
+`src/ai_search/views.py`:
+```python
+import logging
+from datetime import date
+from decimal import Decimal
+from urllib.parse import urlencode
+
+import rollbar
+from django.conf import settings
+from django.http import Http404, HttpResponseRedirect
+from django.urls import reverse
+from django.views.generic import FormView
+
+from bpp.views.zapytanie import WprowadzanieDanychOrSuperuserMixin
+
+from ai_search import budget, fx, pricing, translator
+from ai_search.forms import AISearchForm
+from ai_search.models import AISearchQuery
+
+logger = logging.getLogger(__name__)
+
+
+class ZapytanieAIView(WprowadzanieDanychOrSuperuserMixin, FormView):
+ template_name = "ai_search/zapytanie_ai.html"
+ form_class = AISearchForm
+
+ def dispatch(self, request, *args, **kwargs):
+ if not settings.BPP_AI_SEARCH_ENABLED:
+ raise Http404("Wyszukiwanie AI jest wyłączone.")
+ return super().dispatch(request, *args, **kwargs)
+
+ def form_valid(self, form):
+ model_key = form.cleaned_data["model"]
+ pytanie = form.cleaned_data["pytanie"].strip()
+
+ status = budget.check_budget()
+ if not status.ok:
+ return self.render_to_response(
+ self.get_context_data(form=form, blad=status.reason)
+ )
+
+ try:
+ result = translator.translate(pytanie, model_key)
+ except Exception: # błędy SDK/sieci — log + generyczny komunikat
+ rollbar.report_exc_info()
+ logger.exception("Błąd tłumaczenia AI")
+ return self.render_to_response(
+ self.get_context_data(
+ form=form,
+ blad="Usługa AI jest chwilowo niedostępna. Spróbuj później "
+ "lub użyj „szukaj zapytaniem”.",
+ )
+ )
+
+ self._log(result, model_key, pytanie)
+
+ if result.query:
+ self.request.session["ai_search_last_question"] = pytanie
+ params = urlencode({"model": model_key, "query": result.query})
+ return HttpResponseRedirect(f"{reverse('bpp:zapytanie')}?{params}")
+
+ return self.render_to_response(
+ self.get_context_data(
+ form=form,
+ blad=result.error or "Nie udało się przetłumaczyć pytania.",
+ wygenerowany_query=result.query,
+ )
+ )
+
+ def _log(self, result, model_key, pytanie):
+ rate = fx.usd_to_pln_rate()
+ try:
+ cost_usd = pricing.cost_usd_from_usage(
+ result.usage, settings.BPP_AI_MODEL, date.today()
+ )
+ except KeyError:
+ rollbar.report_exc_info()
+ logger.error("Brak ceny dla modelu %s — koszt nieznany", settings.BPP_AI_MODEL)
+ cost_usd = Decimal("0")
+ AISearchQuery.objects.create(
+ user=self.request.user if self.request.user.is_authenticated else None,
+ model=settings.BPP_AI_MODEL,
+ pytanie=pytanie,
+ wygenerowany_query=result.query or "",
+ wybrany_model_danych=model_key,
+ input_tokens=result.usage.get("input_tokens", 0),
+ output_tokens=result.usage.get("output_tokens", 0),
+ cache_read_tokens=result.usage.get("cache_read_tokens", 0),
+ cache_write_tokens=result.usage.get("cache_write_tokens", 0),
+ cost_usd=cost_usd,
+ fx_rate=rate,
+ cost_pln=(cost_usd * rate).quantize(Decimal("0.0001")),
+ success=bool(result.query),
+ error=result.error,
+ retried=result.retried,
+ )
+```
+
+- [ ] **Step 5: URL apki + include w root**
+
+`src/ai_search/urls.py`:
+```python
+from django.urls import path
+
+from ai_search.views import ZapytanieAIView
+
+app_name = "ai_search"
+
+urlpatterns = [
+ path("", ZapytanieAIView.as_view(), name="index"),
+]
+```
+W `src/django_bpp/urls.py` dodaj do listy tras (obok innych `include`):
+```python
+ path(
+ "ai-search/",
+ include(("ai_search.urls", "ai_search"), namespace="ai_search"),
+ ),
+```
+
+- [ ] **Step 6: Szablon**
+
+`src/ai_search/templates/ai_search/zapytanie_ai.html`:
+```django
+{% extends "base.html" %}
+{% block content %}
+
+
+
Szukaj przez sztuczną inteligencję
+
Zadaj pytanie po polsku — zamienimy je na zapytanie i pokażemy wyniki.
+
+ {% if blad %}
+
{{ blad }}
+ {% endif %}
+ {% if wygenerowany_query %}
+
+ Wygenerowane zapytanie: {{ wygenerowany_query }}
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
+```
+(Jeśli `base.html` ma inną nazwę bloku niż `content` — dopasuj do wzoru z `bpp/zapytanie.html`.)
+
+- [ ] **Step 7: Uruchom testy widoku**
+
+Run: `uv run pytest src/ai_search/tests/test_views.py -v`
+Expected: PASS (5).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/ai_search/forms.py src/ai_search/views.py src/ai_search/urls.py \
+ src/ai_search/templates/ src/django_bpp/urls.py src/ai_search/tests/test_views.py
+git commit -m "feat(ai-search): widok+formularz+URL+szablon (budzet, log, redirect na zapytanie)"
+```
+
+---
+
+## Task 12: Integracja z menu top-bar
+
+**Files:**
+- Modify: `src/django_bpp/templates/top_bar.html:34-41`
+- Test: `src/ai_search/tests/test_menu.py`
+
+**Interfaces:**
+- Consumes: filtr `can_use_query_editor`, `settings.BPP_AI_SEARCH_ENABLED`, `ai_search:index`.
+
+- [ ] **Step 1: Test obecności/gate pozycji menu**
+
+`src/ai_search/tests/test_menu.py`:
+```python
+import pytest
+from django.urls import reverse
+
+
+@pytest.fixture
+def staff_client(client, django_user_model):
+ u = django_user_model.objects.create_user(
+ username="ed", password="x", is_staff=True, is_superuser=True
+ )
+ client.force_login(u)
+ return client
+
+
+@pytest.mark.django_db
+def test_menu_item_shown_when_enabled(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ r = staff_client.get("/")
+ assert reverse("ai_search:index") in r.content.decode()
+
+
+@pytest.mark.django_db
+def test_menu_item_hidden_when_disabled(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = False
+ r = staff_client.get("/")
+ assert reverse("ai_search:index") not in r.content.decode()
+```
+
+- [ ] **Step 2: Uruchom — ma paść**
+
+Run: `uv run pytest src/ai_search/tests/test_menu.py -v`
+Expected: FAIL (linku brak).
+
+- [ ] **Step 3: Udostępnij flagę w kontekście szablonu**
+
+Sprawdź, czy `BPP_AI_SEARCH_ENABLED` jest widoczne w szablonach. Jeśli jest context processor eksponujący `settings` (poszukaj `context_processors` w `settings/base.py`) — użyj go. W przeciwnym razie dodaj lekki context processor:
+`src/ai_search/context_processors.py`:
+```python
+from django.conf import settings
+
+
+def ai_search_flags(request):
+ return {"BPP_AI_SEARCH_ENABLED": settings.BPP_AI_SEARCH_ENABLED}
+```
+i dopisz `"ai_search.context_processors.ai_search_flags"` do `TEMPLATES[0]["OPTIONS"]["context_processors"]` w `base.py`.
+
+- [ ] **Step 4: Dodaj pozycję menu**
+
+W `src/django_bpp/templates/top_bar.html`, wewnątrz `
From fe309ef07d4be0ac536e7e8cfc1684e089d958f0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Sun, 5 Jul 2026 19:37:08 +0200
Subject: [PATCH 18/27] feat(ai-search): regeneracja schematu (beat), admin
logu, accuracy set, testy galezi bledow, docs
Celery beat (ai_search.tasks.regenerate_schemas, codziennie 3:45) odswieza
cache compact-schematu dla "rekord"/"autor", zeby nowe wartosci slownikowe
z bazy pojawialy sie w promptcie LLM bez czekania na TTL. Admin AISearchQuery
jest read-only (log kosztow/audytu). Dodano smoke accuracy-set (skipif bez
ANTHROPIC_API_KEY, wylaczony z realnego CI), management command
ai_search_schema_dump do inspekcji/pomiaru rozmiaru schematu, dwa testy
brakujacych galezi bledow z Task 11 review (translate() rzuca wyjatek ->
przyjazny komunikat + Rollbar; cost_usd_from_usage() rzuca KeyError -> log
z kosztem zerowym + Rollbar) oraz krotka dokumentacja feature'u.
---
docs/deweloper/ai-search.md | 71 +++++++++++++++++++
docs/deweloper/index.md | 2 +
mkdocs.yml | 1 +
src/ai_search/admin.py | 35 +++++++++
src/ai_search/management/__init__.py | 0
src/ai_search/management/commands/__init__.py | 0
.../commands/ai_search_schema_dump.py | 32 +++++++++
src/ai_search/tasks.py | 18 +++++
src/ai_search/tests/test_accuracy.py | 34 +++++++++
src/ai_search/tests/test_views.py | 47 ++++++++++++
src/django_bpp/settings/base.py | 7 ++
11 files changed, 247 insertions(+)
create mode 100644 docs/deweloper/ai-search.md
create mode 100644 src/ai_search/admin.py
create mode 100644 src/ai_search/management/__init__.py
create mode 100644 src/ai_search/management/commands/__init__.py
create mode 100644 src/ai_search/management/commands/ai_search_schema_dump.py
create mode 100644 src/ai_search/tasks.py
create mode 100644 src/ai_search/tests/test_accuracy.py
diff --git a/docs/deweloper/ai-search.md b/docs/deweloper/ai-search.md
new file mode 100644
index 000000000..d8cefbf45
--- /dev/null
+++ b/docs/deweloper/ai-search.md
@@ -0,0 +1,71 @@
+# Wyszukiwanie przez AI (`ai_search`)
+
+Formularz „zapytaj po polsku" (`ai_search.views.ZapytanieAIView`) tłumaczy
+pytanie w języku naturalnym (PL) na zapytanie [DjangoQL](https://github.com/ivelum/djangoql)
+przy pomocy modelu Anthropic Claude, a następnie przekierowuje do
+istniejącego edytora zapytań (`bpp:zapytanie`) z gotowym DSL-em. Aplikacja
+loguje każde zapytanie wraz z kosztem w `ai_search.models.AISearchQuery`
+(admin: read-only, `/admin/ai_search/aisearchquery/`).
+
+## Zmienne środowiskowe
+
+| Zmienna | Domyślna | Znaczenie |
+|---|---|---|
+| `BPP_AI_SEARCH_ENABLED` | `False` | Włącza feature (link w menu + widok; bez tego widok zwraca 404). |
+| `ANTHROPIC_API_KEY` | — | Klucz API Anthropic (wymagany do realnych wywołań; SDK `anthropic` czyta go bezpośrednio ze środowiska). |
+| `BPP_AI_MODEL` | `claude-sonnet-5` | Model używany do tłumaczenia NL->DSL; musi mieć wpis w `BPP_AI_PRICING` (cennik) w `settings/base.py`. |
+| `BPP_AI_DAILY_BUDGET_PLN` / `BPP_AI_MONTHLY_BUDGET_PLN` | `20` / `300` | Twarde limity kosztu (PLN); po przekroczeniu `ai_search.budget.check_budget()` blokuje kolejne zapytania (widok zwraca 200 z komunikatem, nic nie loguje). |
+| `BPP_AI_MAX_RETRIES` | `1` | Ile razy `translator.translate` ponawia zapytanie do modelu po błędzie składni DjangoQL (z konkretnym komunikatem błędu, linia/kolumna). |
+| `BPP_AI_LLM_TIMEOUT` | `30` | Timeout (s) klienta `anthropic.Anthropic`. |
+| `BPP_AI_SCHEMA_CACHE_TTL` | `86400` | TTL (s) cache'a compact-schematu (`ai_search.schema_export`) wysyłanego jako część system prompta. |
+| `BPP_AI_FX_CACHE_TTL` | `86400` | TTL (s) cache'a kursu USD->PLN (NBP). |
+| `BPP_AI_FX_FALLBACK` | `4.5` | Kurs awaryjny, gdy NBP i cache/Redis zawiodą (patrz też `ai_search.models.FxRate` — trwały fallback ostatniego znanego kursu). |
+| `BPP_AI_MAX_FK_OPTIONS` | `100` | Powyżej tego progu `describe_schema_for_llm` nie wypisuje pojedynczych wartości `suggest_options` dla pola FK/wyboru (schemat rośnie liniowo z liczbą opcji). |
+
+## Dane wysyłane do Anthropic
+
+Do modelu trafia (jako część system prompta, patrz `ai_search.prompts` +
+`ai_search.schema_export`):
+
+- **treść pytania użytkownika** (pole `pytanie`),
+- **compact schema** modelu „rekord" lub „autor" — nazwy pól, typy, oraz
+ **`suggested_values`/`suggest_options` — realne wartości z bazy**
+ (np. lista źródeł, dyscyplin, jednostek) dla pól słownikowych, ograniczona
+ przez `BPP_AI_MAX_FK_OPTIONS`. To są **dane z produkcyjnej bazy BPP**, nie
+ dane osobowe pracowników/autorów per se, ale przy konfiguracji progu warto
+ pamiętać, że treść trafia do zewnętrznego API (Anthropic).
+
+Schemat jest cache'owany (`django.core.cache`, klucz
+`ai_search:schema:`) i odświeżany:
+
+- automatycznie po wygaśnięciu TTL (`BPP_AI_SCHEMA_CACHE_TTL`),
+- raz na dobę przez Celery beat (`ai_search.tasks.regenerate_schemas`,
+ wpis `ai-search-regenerate-schemas` w `CELERYBEAT_SCHEDULE`,
+ `src/django_bpp/settings/base.py`, 3:45 w nocy) — żeby nowe wartości
+ słownikowe (nowe źródła, dyscypliny itd.) pojawiły się w schemacie bez
+ czekania na wygaśnięcie cache.
+
+## Jak zmierzyć rozmiar / podejrzeć treść schematu
+
+```bash
+# Wypisz aktualny (cache'owany, budowany w razie braku) schemat na stdout:
+uv run python src/manage.py ai_search_schema_dump rekord
+uv run python src/manage.py ai_search_schema_dump autor
+
+# Wymuś regenerację (pomija cache) i zmierz rozmiar w znakach:
+uv run python src/manage.py ai_search_schema_dump rekord --regenerate | wc -c
+```
+
+Polecenie drukuje też liczbę znaków na stderr (`# rekord: 1234 znaków`) —
+przydatne przy dostrajaniu `BPP_AI_MAX_FK_OPTIONS`, żeby schemat (a więc i
+koszt tokenów input) nie rósł niekontrolowanie wraz z bazą.
+
+## Koszty
+
+`ai_search.pricing.cost_usd_from_usage` liczy koszt (USD) na podstawie
+`usage` zwróconego przez SDK (`input_tokens`, `output_tokens`,
+`cache_read_tokens`, `cache_write_tokens`) i cennika `BPP_AI_PRICING` w
+`settings/base.py` (per model, z opcjonalnym intro-pricingiem do daty).
+Nieznany model w cenniku podnosi `KeyError` — widok łapie ten wyjątek,
+zgłasza do Rollbar i loguje wpis z `cost_usd=0`/`cost_pln=0` zamiast cichego
+zera bez śladu w monitoringu.
diff --git a/docs/deweloper/index.md b/docs/deweloper/index.md
index e47e70a18..6d9081d40 100644
--- a/docs/deweloper/index.md
+++ b/docs/deweloper/index.md
@@ -20,3 +20,5 @@ repozytorium. Poniższe strony rozwijają wybrane tematy:
- [WeasyPrint na macOS](weasyprint-macos.md) — konfiguracja PDF lokalnie.
- [Testy: Channels broadcast (flake)](testy-channels-broadcast.md) —
diagnostyka niestabilnego testu.
+- [Wyszukiwanie przez AI](ai-search.md) — feature „zapytaj po polsku"
+ (NL->DjangoQL), env-vary, koszty, cache schematu.
diff --git a/mkdocs.yml b/mkdocs.yml
index 2364ca9e6..d56e29a21 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -113,6 +113,7 @@ nav:
- Dane demo: deweloper/dane-demo.md
- WeasyPrint na macOS: deweloper/weasyprint-macos.md
- Channels broadcast (flake): deweloper/testy-channels-broadcast.md
+ - Wyszukiwanie przez AI: deweloper/ai-search.md
- Bezpieczeństwo:
- Polityka bezpieczeństwa: bezpieczenstwo/polityka.md
- Praktyki bezpieczeństwa: bezpieczenstwo/praktyki.md
diff --git a/src/ai_search/admin.py b/src/ai_search/admin.py
new file mode 100644
index 000000000..d60e1b0f7
--- /dev/null
+++ b/src/ai_search/admin.py
@@ -0,0 +1,35 @@
+from django.contrib import admin
+
+from ai_search.models import AISearchQuery
+
+
+@admin.register(AISearchQuery)
+class AISearchQueryAdmin(admin.ModelAdmin):
+ """Log zapytań AI (NL->DjangoQL) wraz z kosztem — wyłącznie do odczytu.
+
+ Brak dodawania/edycji: wpisy tworzy tylko ``ZapytanieAIView._log``
+ (ślad audytowy kosztów i skuteczności tłumaczenia, nie dane do ręcznej
+ edycji)."""
+
+ list_display = (
+ "created",
+ "user",
+ "wybrany_model_danych",
+ "success",
+ "cost_pln",
+ "cost_usd",
+ "retried",
+ )
+ list_filter = ("success", "wybrany_model_danych", "retried", "created")
+ search_fields = ("pytanie", "wygenerowany_query")
+ readonly_fields = [f.name for f in AISearchQuery._meta.fields]
+ date_hierarchy = "created"
+
+ def has_add_permission(self, request):
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return request.user.is_superuser
diff --git a/src/ai_search/management/__init__.py b/src/ai_search/management/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/ai_search/management/commands/__init__.py b/src/ai_search/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/ai_search/management/commands/ai_search_schema_dump.py b/src/ai_search/management/commands/ai_search_schema_dump.py
new file mode 100644
index 000000000..b740a22fd
--- /dev/null
+++ b/src/ai_search/management/commands/ai_search_schema_dump.py
@@ -0,0 +1,32 @@
+from django.core.management import BaseCommand
+
+from ai_search import schema_export
+
+
+class Command(BaseCommand):
+ help = (
+ "Wypisuje na stdout wygenerowany (compact) opis schematu wysyłany do "
+ "LLM dla danego modelu ('rekord' lub 'autor') — przydatne do ręcznej "
+ "inspekcji treści oraz do zmierzenia rozmiaru (np. `| wc -c`)."
+ )
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "model_key",
+ choices=("rekord", "autor"),
+ help="Klucz modelu danych (patrz bpp.views.zapytanie.MODELS).",
+ )
+ parser.add_argument(
+ "--regenerate",
+ action="store_true",
+ help="Pomiń cache i zbuduj schemat od nowa (zapisze go też do cache).",
+ )
+
+ def handle(self, *args, **options):
+ model_key = options["model_key"]
+ if options["regenerate"]:
+ data = schema_export.regenerate(model_key)
+ else:
+ data = schema_export.schema_for_llm(model_key)
+ self.stdout.write(data)
+ self.stderr.write(f"# {model_key}: {len(data)} znaków")
diff --git a/src/ai_search/tasks.py b/src/ai_search/tasks.py
new file mode 100644
index 000000000..333339f4d
--- /dev/null
+++ b/src/ai_search/tasks.py
@@ -0,0 +1,18 @@
+from celery.utils.log import get_task_logger
+
+from ai_search import schema_export
+from django_bpp.celery_tasks import app
+
+logger = get_task_logger(__name__)
+
+
+@app.task(ignore_result=True)
+def regenerate_schemas():
+ """Odśwież cache'owany, zwarty opis schematu (dla LLM) dla „rekord"
+ i „autor". Uruchamiane raz/dobę przez CELERYBEAT_SCHEDULE — schemat
+ zawiera suggest_options wyciągane z bazy (np. wartości słownikowe),
+ więc bez regeneracji cache trzymałby stare dane aż do wygaśnięcia TTL
+ (``BPP_AI_SCHEMA_CACHE_TTL``)."""
+ for key in ("rekord", "autor"):
+ schema_export.regenerate(key)
+ logger.info("ai_search: zregenerowano schemat dla %r", key)
diff --git a/src/ai_search/tests/test_accuracy.py b/src/ai_search/tests/test_accuracy.py
new file mode 100644
index 000000000..308073a30
--- /dev/null
+++ b/src/ai_search/tests/test_accuracy.py
@@ -0,0 +1,34 @@
+"""Smoke-test dokładności tłumaczenia NL->DjangoQL na realnym modelu.
+
+Wymaga ``ANTHROPIC_API_KEY`` (realne wywołanie SDK, koszt + czas sieciowy) —
+pomijany domyślnie i wykluczony z CI (patrz ``[tool.pytest.ini_options]``
+w pyproject.toml, `norecursedirs`/`addopts` albo dedykowany marker, jeśli CI
+selekcjonuje po ścieżkach).
+
+Docelowo 30-50 par pytanie->fragment DSL; tu smoke set (kilka
+reprezentatywnych przypadków) — pełny zestaw accuracy wymaga ręcznej
+kuracji przez osobę znającą dane demo/testowe.
+"""
+
+import os
+
+import pytest
+
+from ai_search import translator
+
+ANTHROPIC = bool(os.environ.get("ANTHROPIC_API_KEY"))
+pytestmark = pytest.mark.skipif(not ANTHROPIC, reason="brak ANTHROPIC_API_KEY")
+
+CASES = [
+ ("rekord", "publikacje z 2024 roku", "rok"),
+ ("rekord", "prace bez źródła", "None"),
+ ("autor", "autorzy z ORCID", "orcid"),
+]
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("model_key,pytanie,expect_fragment", CASES)
+def test_translates_reasonably(model_key, pytanie, expect_fragment):
+ res = translator.translate(pytanie, model_key)
+ assert res.query is not None
+ assert expect_fragment in res.query
diff --git a/src/ai_search/tests/test_views.py b/src/ai_search/tests/test_views.py
index aeb55c6a0..9e19d9de0 100644
--- a/src/ai_search/tests/test_views.py
+++ b/src/ai_search/tests/test_views.py
@@ -66,6 +66,53 @@ def test_post_blocked_by_budget(staff_client, settings):
assert AISearchQuery.objects.count() == 0
+@pytest.mark.django_db
+def test_post_translate_raises_shows_friendly_error(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ with (
+ mock.patch(
+ "ai_search.views.translator.translate",
+ side_effect=Exception("boom"),
+ ),
+ mock.patch("ai_search.views.rollbar.report_exc_info") as report,
+ ):
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "cokolwiek"},
+ )
+ assert r.status_code == 200
+ assert "chwilowo niedostępna" in r.content.decode()
+ report.assert_called_once()
+ assert AISearchQuery.objects.count() == 0
+
+
+@pytest.mark.django_db
+def test_post_pricing_keyerror_logs_zero_cost(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ res = translator.TranslationResult(
+ query="rok = 2024", usage={"input_tokens": 10, "output_tokens": 5}, attempts=1
+ )
+ with (
+ mock.patch("ai_search.views.translator.translate", return_value=res),
+ mock.patch("ai_search.views.fx.usd_to_pln_rate", return_value=Decimal("4.1")),
+ mock.patch(
+ "ai_search.views.pricing.cost_usd_from_usage",
+ side_effect=KeyError("nieznany-model"),
+ ),
+ mock.patch("ai_search.views.rollbar.report_exc_info") as report,
+ ):
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "publikacje z 2024"},
+ )
+ assert r.status_code == 302
+ report.assert_called_once()
+ log = AISearchQuery.objects.get()
+ assert log.success is True
+ assert log.cost_usd == Decimal("0")
+ assert log.cost_pln == Decimal("0")
+
+
@pytest.mark.django_db
def test_post_null_query_shows_error(staff_client, settings):
settings.BPP_AI_SEARCH_ENABLED = True
diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py
index 6f5f49184..80ff6b923 100644
--- a/src/django_bpp/settings/base.py
+++ b/src/django_bpp/settings/base.py
@@ -816,6 +816,13 @@ def autoslug_gen():
"task": "bpp.tasks.usun_stare_logi_logowania_easyaudit",
"schedule": crontab(hour=2, minute=0, day_of_month=1),
},
+ # Odśwież cache'owany, zwarty opis schematu (dla LLM) wyszukiwania przez
+ # AI — zawiera suggest_options z bazy (wartości słownikowe), więc bez
+ # regeneracji trzymałby stare dane aż do wygaśnięcia TTL.
+ "ai-search-regenerate-schemas": {
+ "task": "ai_search.tasks.regenerate_schemas",
+ "schedule": crontab(hour=3, minute=45), # Daily at 3:45 AM
+ },
}
From 444e9ac21f0fa96a6efbe9c4920026e9c1c128f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Sun, 5 Jul 2026 19:59:41 +0200
Subject: [PATCH 19/27] fix(ai-search): budzet re-check per retry + cap
retries=2 + drobne (FxRate tiebreaker, martwy kwarg/logger, docstring)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../migrations/0002_alter_fxrate_options.py | 16 ++++++
src/ai_search/models.py | 2 +-
src/ai_search/schema_export.py | 4 --
src/ai_search/tests/test_accuracy.py | 9 ++--
src/ai_search/tests/test_translator.py | 49 ++++++++++++++++++-
src/ai_search/tests/test_views.py | 30 ++++++++++++
src/ai_search/translator.py | 22 +++++++--
src/ai_search/views.py | 14 +++++-
8 files changed, 131 insertions(+), 15 deletions(-)
create mode 100644 src/ai_search/migrations/0002_alter_fxrate_options.py
diff --git a/src/ai_search/migrations/0002_alter_fxrate_options.py b/src/ai_search/migrations/0002_alter_fxrate_options.py
new file mode 100644
index 000000000..f2288fcde
--- /dev/null
+++ b/src/ai_search/migrations/0002_alter_fxrate_options.py
@@ -0,0 +1,16 @@
+# Generated by Django 5.2.15 on 2026-07-05 17:58
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ai_search", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="fxrate",
+ options={"ordering": ("-fetched_at", "-id")},
+ ),
+ ]
diff --git a/src/ai_search/models.py b/src/ai_search/models.py
index 6536b2b86..a1ddd4eae 100644
--- a/src/ai_search/models.py
+++ b/src/ai_search/models.py
@@ -46,7 +46,7 @@ class FxRate(models.Model):
fetched_at = models.DateTimeField(auto_now_add=True)
class Meta:
- ordering = ("-fetched_at",)
+ ordering = ("-fetched_at", "-id")
@classmethod
def latest(cls):
diff --git a/src/ai_search/schema_export.py b/src/ai_search/schema_export.py
index 0078280b3..030df722b 100644
--- a/src/ai_search/schema_export.py
+++ b/src/ai_search/schema_export.py
@@ -1,5 +1,3 @@
-import logging
-
from django.conf import settings
from django.core.cache import cache
from djangoql.llm import describe_schema_for_llm
@@ -7,8 +5,6 @@
from bpp.djangoql_schema import BppQLSchema
from bpp.views.zapytanie import MODELS
-logger = logging.getLogger(__name__)
-
def _cache_key(model_key: str) -> str:
return f"ai_search:schema:{model_key}"
diff --git a/src/ai_search/tests/test_accuracy.py b/src/ai_search/tests/test_accuracy.py
index 308073a30..c1cc6138b 100644
--- a/src/ai_search/tests/test_accuracy.py
+++ b/src/ai_search/tests/test_accuracy.py
@@ -1,9 +1,10 @@
"""Smoke-test dokładności tłumaczenia NL->DjangoQL na realnym modelu.
-Wymaga ``ANTHROPIC_API_KEY`` (realne wywołanie SDK, koszt + czas sieciowy) —
-pomijany domyślnie i wykluczony z CI (patrz ``[tool.pytest.ini_options]``
-w pyproject.toml, `norecursedirs`/`addopts` albo dedykowany marker, jeśli CI
-selekcjonuje po ścieżkach).
+Wymaga ``ANTHROPIC_API_KEY`` (realne wywołanie SDK, koszt + czas sieciowy).
+Nie ma żadnego strukturalnego wykluczenia z CI po ścieżce/markerze —
+pomijany jest wyłącznie przez ``pytestmark = pytest.mark.skipif(not
+ANTHROPIC, ...)`` poniżej: gdy zmienna środowiskowa jest ustawiona (np.
+lokalnie albo w dedykowanym jobie CI z sekretem), test się wykona.
Docelowo 30-50 par pytanie->fragment DSL; tu smoke set (kilka
reprezentatywnych przypadków) — pełny zestaw accuracy wymaga ręcznej
diff --git a/src/ai_search/tests/test_translator.py b/src/ai_search/tests/test_translator.py
index a0c6e6523..35ed12661 100644
--- a/src/ai_search/tests/test_translator.py
+++ b/src/ai_search/tests/test_translator.py
@@ -2,7 +2,7 @@
import pytest
-from ai_search import translator
+from ai_search import budget, translator
def _fake_response(query, error=None, usage=None):
@@ -72,3 +72,50 @@ def test_invalid_after_retries_returns_error(settings):
assert res.query is None
assert res.error # komunikat błędu DjangoQL
assert call.call_count == 2 # 1 + 1 retry
+
+
+@pytest.mark.django_db
+def test_max_retries_capped_at_two(settings):
+ """Spec: default 1, cap 2 — niezależnie od ustawienia, maks. 1 + 2."""
+ settings.BPP_AI_MAX_RETRIES = 10
+ bad = _fake_response('rok = "x')
+ with mock.patch.object(translator, "_call_model", return_value=bad) as call:
+ res = translator.translate("prace z 2024", "rekord")
+ assert res.query is None
+ assert call.call_count == 3 # 1 + cap(2) retry, nie 1 + 10
+
+
+@pytest.mark.django_db
+def test_budget_check_blocks_retry_mid_loop():
+ """budget_check zwraca ok=True przed 1. próbą, ok=False przed 2. —
+ retry NIE może zostać wykonany, mimo że 1. próba dała niepoprawny query."""
+ statuses = [
+ budget.BudgetStatus(ok=True),
+ budget.BudgetStatus(ok=False, reason="limit"),
+ ]
+ bad = _fake_response('rok = "x')
+ with mock.patch.object(translator, "_call_model", return_value=bad) as call:
+ res = translator.translate(
+ "prace z 2024",
+ "rekord",
+ budget_check=mock.Mock(side_effect=statuses),
+ )
+ assert call.call_count == 1
+ assert res.budget_blocked is True
+ assert res.error == "limit"
+ assert res.query is None
+
+
+@pytest.mark.django_db
+def test_budget_check_blocks_before_first_call():
+ """budget_check już zablokowany przy pierwszej iteracji — _call_model
+ nigdy nie zostaje wywołany."""
+ with mock.patch.object(translator, "_call_model") as call:
+ res = translator.translate(
+ "prace z 2024",
+ "rekord",
+ budget_check=lambda: budget.BudgetStatus(ok=False, reason="limit"),
+ )
+ call.assert_not_called()
+ assert res.budget_blocked is True
+ assert res.error == "limit"
diff --git a/src/ai_search/tests/test_views.py b/src/ai_search/tests/test_views.py
index 9e19d9de0..f4df4b692 100644
--- a/src/ai_search/tests/test_views.py
+++ b/src/ai_search/tests/test_views.py
@@ -66,6 +66,36 @@ def test_post_blocked_by_budget(staff_client, settings):
assert AISearchQuery.objects.count() == 0
+@pytest.mark.django_db
+def test_post_budget_blocked_mid_retry_logs_cost_and_renders_message(
+ staff_client, settings
+):
+ """Blok budżetu W TRAKCIE retry (po >=1 płatnej próbie) — w odróżnieniu
+ od pre-checku (test_post_blocked_by_budget) — loguje poniesiony koszt
+ i renderuje komunikat budżetowy."""
+ settings.BPP_AI_SEARCH_ENABLED = True
+ res = translator.TranslationResult(
+ query=None,
+ error="Dzienny limit kosztów AI został osiągnięty.",
+ usage={"input_tokens": 10, "output_tokens": 5},
+ attempts=1,
+ budget_blocked=True,
+ )
+ with (
+ mock.patch("ai_search.views.translator.translate", return_value=res),
+ mock.patch("ai_search.views.fx.usd_to_pln_rate", return_value=Decimal("4.1")),
+ ):
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "cokolwiek"},
+ )
+ assert r.status_code == 200
+ assert "limit" in r.content.decode().lower()
+ log = AISearchQuery.objects.get()
+ assert log.success is False
+ assert log.cost_pln > 0
+
+
@pytest.mark.django_db
def test_post_translate_raises_shows_friendly_error(staff_client, settings):
settings.BPP_AI_SEARCH_ENABLED = True
diff --git a/src/ai_search/translator.py b/src/ai_search/translator.py
index d13d07f3a..b7e858422 100644
--- a/src/ai_search/translator.py
+++ b/src/ai_search/translator.py
@@ -40,6 +40,7 @@ class TranslationResult:
usage: dict = field(default_factory=dict)
attempts: int = 0
retried: bool = False
+ budget_blocked: bool = False
def validate_query(query: str, model_key: str):
@@ -86,16 +87,31 @@ def _accumulate(total: dict, part: dict):
total[k] = total.get(k, 0) + v
-def translate(pytanie: str, model_key: str) -> TranslationResult:
+def translate(pytanie: str, model_key: str, budget_check=None) -> TranslationResult:
"""NL (polski) -> DjangoQL. Waliduje i, przy błędzie składni, zwraca do
- modelu konkretny komunikat DjangoQL (linia/kolumna) i ponawia — bounded."""
+ modelu konkretny komunikat DjangoQL (linia/kolumna) i ponawia — bounded.
+
+ ``budget_check`` (opcjonalnie) to callable bez argumentów zwracający
+ obiekt z atrybutami ``.ok``/``.reason`` (np. ``budget.check_budget``).
+ Jest sprawdzany na POCZĄTKU każdej iteracji pętli — także przed każdym
+ retry — żeby budżet wyczerpany W TRAKCIE bounded-retry przerwał dalsze
+ (płatne) wywołania modelu."""
system = prompts.build_system(schema_export.schema_for_llm(model_key), model_key)
- max_retries = settings.BPP_AI_MAX_RETRIES
+ # cap 2 per spec (domyślnie 1, ale niezależnie od ustawienia max. 2 retry)
+ max_retries = min(settings.BPP_AI_MAX_RETRIES, 2)
total_usage: dict = {}
result = TranslationResult()
content = pytanie
for attempt in range(max_retries + 1):
+ if budget_check is not None:
+ status = budget_check()
+ if not status.ok:
+ result.budget_blocked = True
+ result.error = status.reason
+ result.query = None
+ return result
+
result.attempts = attempt + 1
resp = _call_model(system, [{"role": "user", "content": content}])
_accumulate(total_usage, _extract_usage(resp))
diff --git a/src/ai_search/views.py b/src/ai_search/views.py
index 7cf589bb5..01d458118 100644
--- a/src/ai_search/views.py
+++ b/src/ai_search/views.py
@@ -40,7 +40,9 @@ def form_valid(self, form):
)
try:
- result = translator.translate(pytanie, model_key)
+ result = translator.translate(
+ pytanie, model_key, budget_check=budget.check_budget
+ )
except Exception: # błędy SDK/sieci — log + generyczny komunikat
rollbar.report_exc_info()
logger.exception("Błąd tłumaczenia AI")
@@ -54,6 +56,15 @@ def form_valid(self, form):
self._log(result, model_key, pytanie)
+ if result.budget_blocked:
+ # Budżet wyczerpał się W TRAKCIE bounded-retry, po co najmniej
+ # jednej płatnej próbie — w odróżnieniu od pre-checku wyżej
+ # (tam: brak wywołania, brak logu), tu koszt już poniesiony
+ # jest zalogowany przez self._log() powyżej.
+ return self.render_to_response(
+ self.get_context_data(form=form, blad=result.error)
+ )
+
if result.query:
self.request.session["ai_search_last_question"] = pytanie
params = urlencode({"model": model_key, "query": result.query})
@@ -63,7 +74,6 @@ def form_valid(self, form):
self.get_context_data(
form=form,
blad=result.error or "Nie udało się przetłumaczyć pytania.",
- wygenerowany_query=result.query,
)
)
From 74816e2a305096c4a83f1dd294224583d0a92323 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Sun, 5 Jul 2026 20:14:32 +0200
Subject: [PATCH 20/27] feat(ai-search): konfigurowalny backend LLM (Anthropic
+ OpenAI-compatible dla modeli lokalnych) + docs
Nowy ai_search/backends.py wydziela wywolanie modelu z translator.py:
AnthropicBackend (natywny SDK, bez zmiany zachowania) i
OpenAICompatibleBackend (openai SDK, dla Ollama/llama.cpp/vLLM/LM
Studio/LocalAI). Wybor przez BPP_AI_BACKEND (domyslnie "anthropic").
Widok pomija budzet/cennik/FX dla backendu "openai" (lokalny = darmowy).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/deweloper/ai-search.md | 105 ++++++++++++++-
pyproject.toml | 1 +
src/ai_search/backends.py | 142 ++++++++++++++++++++
src/ai_search/tests/test_backends.py | 172 +++++++++++++++++++++++++
src/ai_search/tests/test_translator.py | 22 ++--
src/ai_search/tests/test_views.py | 34 +++++
src/ai_search/translator.py | 53 +++-----
src/ai_search/views.py | 41 +++---
src/django_bpp/settings/base.py | 9 ++
uv.lock | 21 +++
10 files changed, 533 insertions(+), 67 deletions(-)
create mode 100644 src/ai_search/backends.py
create mode 100644 src/ai_search/tests/test_backends.py
diff --git a/docs/deweloper/ai-search.md b/docs/deweloper/ai-search.md
index d8cefbf45..dfcfe2bb7 100644
--- a/docs/deweloper/ai-search.md
+++ b/docs/deweloper/ai-search.md
@@ -12,8 +12,11 @@ loguje każde zapytanie wraz z kosztem w `ai_search.models.AISearchQuery`
| Zmienna | Domyślna | Znaczenie |
|---|---|---|
| `BPP_AI_SEARCH_ENABLED` | `False` | Włącza feature (link w menu + widok; bez tego widok zwraca 404). |
-| `ANTHROPIC_API_KEY` | — | Klucz API Anthropic (wymagany do realnych wywołań; SDK `anthropic` czyta go bezpośrednio ze środowiska). |
-| `BPP_AI_MODEL` | `claude-sonnet-5` | Model używany do tłumaczenia NL->DSL; musi mieć wpis w `BPP_AI_PRICING` (cennik) w `settings/base.py`. |
+| `ANTHROPIC_API_KEY` | — | Klucz API Anthropic (wymagany do realnych wywołań backendu `anthropic`; SDK `anthropic` czyta go bezpośrednio ze środowiska). |
+| `BPP_AI_BACKEND` | `anthropic` | `anthropic` (natywny SDK, płatny, budżet PLN) albo `openai` (lokalny/self-hosted serwer OpenAI-compatible — darmowy, budżet nieaktywny). Patrz [„Modele lokalne"](#modele-lokalne) niżej. |
+| `BPP_AI_BASE_URL` | `""` | Tylko dla `BPP_AI_BACKEND=openai` — adres API zgodny z OpenAI (np. `http://localhost:11434/v1` dla Ollama). |
+| `BPP_AI_API_KEY` | `""` | Tylko dla `BPP_AI_BACKEND=openai` — klucz API (pusty dla serwerów bez auth, np. Ollama). |
+| `BPP_AI_MODEL` | `claude-sonnet-5` | Model używany do tłumaczenia NL->DSL. Dla `anthropic` musi mieć wpis w `BPP_AI_PRICING` (cennik) w `settings/base.py`; dla `openai` to nazwa modelu na lokalnym serwerze (np. `qwen3:8b`). |
| `BPP_AI_DAILY_BUDGET_PLN` / `BPP_AI_MONTHLY_BUDGET_PLN` | `20` / `300` | Twarde limity kosztu (PLN); po przekroczeniu `ai_search.budget.check_budget()` blokuje kolejne zapytania (widok zwraca 200 z komunikatem, nic nie loguje). |
| `BPP_AI_MAX_RETRIES` | `1` | Ile razy `translator.translate` ponawia zapytanie do modelu po błędzie składni DjangoQL (z konkretnym komunikatem błędu, linia/kolumna). |
| `BPP_AI_LLM_TIMEOUT` | `30` | Timeout (s) klienta `anthropic.Anthropic`. |
@@ -69,3 +72,101 @@ koszt tokenów input) nie rósł niekontrolowanie wraz z bazą.
Nieznany model w cenniku podnosi `KeyError` — widok łapie ten wyjątek,
zgłasza do Rollbar i loguje wpis z `cost_usd=0`/`cost_pln=0` zamiast cichego
zera bez śladu w monitoringu.
+
+To dotyczy wyłącznie backendu `anthropic` — dla `BPP_AI_BACKEND=openai`
+(model lokalny) widok w ogóle pomija pre-check budżetu i cennik/FX; koszt
+jest zawsze logowany jako `0` (patrz sekcja niżej).
+
+## Modele lokalne
+
+### Backendy
+
+`ai_search.backends.get_backend()` wybiera implementację wg
+`settings.BPP_AI_BACKEND`:
+
+- **`anthropic`** (domyślny) — natywny SDK `anthropic`, `messages.parse`
+ z ustrukturyzowanym `output_format`, prompt caching (blok `system` z
+ `cache_control: ephemeral`) i pełny cennik/budżet PLN
+ (`ai_search.budget`, `ai_search.pricing`, `ai_search.fx`).
+- **`openai`** — dowolny lokalny/self-hosted serwer zgodny z OpenAI Chat
+ Completions API: [Ollama](https://ollama.com/),
+ [llama.cpp/llama-server](https://github.com/ggml-org/llama.cpp),
+ [vLLM](https://github.com/vllm-project/vllm),
+ [LM Studio](https://lmstudio.ai/), [LocalAI](https://localai.io/) — ten
+ sam backend obsługuje wszystkie, wystarczy inny `BPP_AI_BASE_URL`.
+ Prosi model o JSON zgodny ze schematem `DSLQuery`
+ (`response_format={"type": "json_schema", ...}`, `strict: True`),
+ `temperature=0`. Darmowy: widok (`ai_search.views.ZapytanieAIView`)
+ pomija pre-check budżetu, nie przekazuje `budget_check` do
+ `translator.translate`, i loguje `cost_usd=0`/`fx_rate=0`/`cost_pln=0`
+ bez wołania `pricing`/`fx` (model lokalny i tak nie ma wpisu w
+ `BPP_AI_PRICING`).
+
+Konfiguracja: `BPP_AI_BACKEND` / `BPP_AI_BASE_URL` / `BPP_AI_API_KEY` /
+`BPP_AI_MODEL` (patrz tabela zmiennych środowiskowych wyżej).
+
+### Przykład: Ollama
+
+```bash
+ollama pull qwen3:8b
+ollama serve
+```
+
+```bash
+# .env / środowisko:
+BPP_AI_SEARCH_ENABLED=1
+BPP_AI_BACKEND=openai
+BPP_AI_BASE_URL=http://localhost:11434/v1
+BPP_AI_MODEL=qwen3:8b
+# BPP_AI_API_KEY pozostaw puste — Ollama nie wymaga auth.
+```
+
+Dla llama.cpp/llama-server, vLLM, LM Studio czy LocalAI zmienia się
+wyłącznie `BPP_AI_BASE_URL` (i ewentualnie `BPP_AI_API_KEY`) — sam backend
+(`openai`) zostaje ten sam.
+
+### Szacunek kontekstu (WAŻNE przy doborze modelu)
+
+Rozmiar system prompta wysyłanego do modelu to głównie compact-schema
+(`ai_search.schema_export`, patrz `ai_search_schema_dump` wyżej) + reguły
+i few-shot z `ai_search.prompts` + samo pytanie i miejsce na wyjście:
+
+- **Model „rekord", ze słownikowymi wartościami**
+ (`BPP_AI_MAX_FK_OPTIONS=100`, domyślne): **~31k tokenów**.
+- **Model „rekord", bez wartości słownikowych** (`BPP_AI_MAX_FK_OPTIONS=0`):
+ **~22k tokenów** — chudszy schemat, ale gorsza trafność dla pytań
+ odwołujących się do konkretnych wartości (np. nazw źródeł/dyscyplin),
+ bo model ich po prostu nie widzi.
+- Do tego reguły + few-shot (~1-2k tokenów) oraz pytanie + miejsce na
+ wyjście (~0,5k).
+- Model „autor" ma zauważalnie mniejszy schemat niż „rekord".
+
+**Zalecane okno kontekstu modelu lokalnego: ≥ 32k tokenów** — komfortowo
+dla wariantu z wartościami słownikowymi (domyślny). Dla modeli z
+kontekstem 8k/16k: ustaw `BPP_AI_MAX_FK_OPTIONS=0` (chudszy ~22k schemat)
+i licz się z ciaśniejszym zapasem na resztę promptu; ewentualnie ogranicz
+się do modeli o szerszym oknie.
+
+### Zalecane modele OSS
+
+Klasa: mały-średni model instruct, dobry w ustrukturyzowanym JSON,
+kontekst ≥32k, rozumiejący polski (samo pytanie jest po polsku — DSL
+wyjściowy jest już w tokenach angielskich/DjangoQL):
+
+- **Qwen3** — rekomendacja główna (najlepszy stosunek JSON+PL wśród
+ modeli tej klasy). **4B/8B** dla lekkich instalacji, **14B/32B** dla
+ lepszej trafności na pytaniach dwuznacznych po polsku.
+- **Llama 3.1 8B** (128k ctx), **Gemma 2 9B**, **Mistral** — alternatywy,
+ jeśli Qwen3 nie jest dostępny lub preferowany.
+- Dla twardej gwarancji poprawnej składni JSON: **llama.cpp z GBNF**
+ (grammar-constrained decoding) — wymusza strukturę na poziomie
+ dekodowania, niezależnie od jakości modelu.
+
+**Uwaga kluczowa:** niezależnie od wybranego modelu lokalnego, gwarancją
+poprawności końcowego zapytania jest walidator DjangoQL
+(`translator.validate_query`, realny parser + `BppQLSchema`) w połączeniu
+z bounded-retry (do `BPP_AI_MAX_RETRIES`, max. 2) — to jest mechanizm
+**niezależny od backendu**. Słabszy model lokalny, który zwróci błędną
+składnię, dostaje z powrotem dokładny komunikat błędu (linia/kolumna) i
+ma szansę się poprawić, zupełnie tak samo jak przy backendzie
+`anthropic`.
diff --git a/pyproject.toml b/pyproject.toml
index 924178bd7..27a715ff7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -109,6 +109,7 @@ dependencies = [
"django-constance>=4.3.5",
"djangoql-iplweb>=0.31.1",
"anthropic>=0.40",
+ "openai>=1.50",
"django-weasyprint>=2.5.0",
"django-templated-email>=3.0.0",
"django-formtools>=2.7,<3",
diff --git a/src/ai_search/backends.py b/src/ai_search/backends.py
new file mode 100644
index 000000000..f13679548
--- /dev/null
+++ b/src/ai_search/backends.py
@@ -0,0 +1,142 @@
+"""Backendy LLM dla tłumacza pytań PL -> DjangoQL.
+
+Dwa backendy za wspólnym kontraktem ``call(system, messages) -> LLMResult``:
+
+- ``AnthropicBackend`` — natywny SDK ``anthropic``, ``messages.parse`` ze
+ structured output (``DSLQuery``), prompt caching (bloki ``system`` z
+ ``cache_control``), thinking wyłączony. Domyślny, płatny.
+- ``OpenAICompatibleBackend`` — SDK ``openai`` wskazany na dowolny lokalny
+ serwer zgodny z OpenAI Chat Completions API (Ollama, llama.cpp/llama-server,
+ vLLM, LM Studio, LocalAI). Spłaszcza bloki ``system`` do zwykłego stringa
+ (lokalne serwery nie znają ``cache_control``) i prosi o JSON zgodny ze
+ schematem ``DSLQuery`` przez ``response_format={"type": "json_schema", ...}``.
+
+``system`` w obu przypadkach to lista bloków anthropic
+(``[{"type": "text", "text": ..., "cache_control": {...}}]``), a ``messages``
+to ``[{"role": "user", "content": str}]`` — kontrakt ustalony przez
+dotychczasowe wywołanie w ``translator.py``.
+"""
+
+import json
+import logging
+from dataclasses import dataclass, field
+
+import anthropic
+from django.conf import settings
+from pydantic import BaseModel, ConfigDict, ValidationError
+
+logger = logging.getLogger(__name__)
+
+
+class DSLQuery(BaseModel):
+ """Ustrukturyzowana odpowiedź modelu (``output_format`` / JSON schema)."""
+
+ model_config = ConfigDict(extra="forbid")
+ query: str | None
+ error: str | None
+
+
+@dataclass
+class LLMResult:
+ """Znormalizowany wynik wywołania modelu, niezależny od backendu."""
+
+ parsed: DSLQuery
+ usage: dict = field(default_factory=dict)
+ stop_reason: str | None = None
+
+
+class AnthropicBackend:
+ """Natywny SDK ``anthropic`` — structured output, prompt caching."""
+
+ def _client(self) -> anthropic.Anthropic:
+ return anthropic.Anthropic(timeout=settings.BPP_AI_LLM_TIMEOUT)
+
+ def _extract_usage(self, resp) -> dict:
+ u = resp.usage
+ return {
+ "input_tokens": getattr(u, "input_tokens", 0) or 0,
+ "output_tokens": getattr(u, "output_tokens", 0) or 0,
+ "cache_read_tokens": getattr(u, "cache_read_input_tokens", 0) or 0,
+ "cache_write_tokens": getattr(u, "cache_creation_input_tokens", 0) or 0,
+ }
+
+ def call(self, system, messages) -> LLMResult:
+ resp = self._client().messages.parse(
+ model=settings.BPP_AI_MODEL,
+ max_tokens=500,
+ thinking={"type": "disabled"},
+ system=system,
+ messages=messages,
+ output_format=DSLQuery,
+ )
+ return LLMResult(
+ parsed=resp.parsed_output,
+ usage=self._extract_usage(resp),
+ stop_reason=getattr(resp, "stop_reason", None),
+ )
+
+
+class OpenAICompatibleBackend:
+ """Dowolny lokalny serwer zgodny z OpenAI Chat Completions API."""
+
+ def _client(self):
+ from openai import OpenAI
+
+ return OpenAI(
+ base_url=settings.BPP_AI_BASE_URL,
+ api_key=settings.BPP_AI_API_KEY or "sk-noauth",
+ timeout=settings.BPP_AI_LLM_TIMEOUT,
+ )
+
+ @staticmethod
+ def _flatten_system(system) -> str:
+ return "\n".join(block["text"] for block in system)
+
+ def call(self, system, messages) -> LLMResult:
+ system_text = self._flatten_system(system)
+ user_content = messages[-1]["content"]
+ resp = self._client().chat.completions.create(
+ model=settings.BPP_AI_MODEL,
+ max_tokens=500,
+ temperature=0,
+ messages=[
+ {"role": "system", "content": system_text},
+ {"role": "user", "content": user_content},
+ ],
+ response_format={
+ "type": "json_schema",
+ "json_schema": {
+ "name": "dsl_query",
+ "schema": DSLQuery.model_json_schema(),
+ "strict": True,
+ },
+ },
+ )
+ choice = resp.choices[0]
+ raw = choice.message.content
+ try:
+ data = json.loads(raw)
+ parsed = DSLQuery(**data)
+ except (json.JSONDecodeError, TypeError, ValidationError):
+ logger.warning("Model lokalny zwrócił niepoprawny JSON: %r", raw)
+ parsed = DSLQuery(
+ query=None, error="Model lokalny zwrócił niepoprawny JSON."
+ )
+ usage = resp.usage
+ return LLMResult(
+ parsed=parsed,
+ usage={
+ "input_tokens": getattr(usage, "prompt_tokens", 0) or 0,
+ "output_tokens": getattr(usage, "completion_tokens", 0) or 0,
+ "cache_read_tokens": 0,
+ "cache_write_tokens": 0,
+ },
+ stop_reason=choice.finish_reason,
+ )
+
+
+def get_backend():
+ """Wybiera backend wg ``settings.BPP_AI_BACKEND`` (domyślnie anthropic)."""
+ if settings.BPP_AI_BACKEND == "openai":
+ return OpenAICompatibleBackend()
+ return AnthropicBackend()
diff --git a/src/ai_search/tests/test_backends.py b/src/ai_search/tests/test_backends.py
new file mode 100644
index 000000000..f8846e45e
--- /dev/null
+++ b/src/ai_search/tests/test_backends.py
@@ -0,0 +1,172 @@
+import json
+from unittest import mock
+
+import pytest
+
+from ai_search import backends
+
+
+def test_get_backend_default_is_anthropic(settings):
+ settings.BPP_AI_BACKEND = "anthropic"
+ assert isinstance(backends.get_backend(), backends.AnthropicBackend)
+
+
+def test_get_backend_openai(settings):
+ settings.BPP_AI_BACKEND = "openai"
+ assert isinstance(backends.get_backend(), backends.OpenAICompatibleBackend)
+
+
+def test_get_backend_unknown_falls_back_to_anthropic(settings):
+ settings.BPP_AI_BACKEND = "cos-nieznanego"
+ assert isinstance(backends.get_backend(), backends.AnthropicBackend)
+
+
+def _system_blocks():
+ return [{"type": "text", "text": "REGULY", "cache_control": {"type": "ephemeral"}}]
+
+
+def test_anthropic_backend_call_maps_usage_and_stop_reason(settings):
+ settings.BPP_AI_MODEL = "claude-sonnet-5"
+ settings.BPP_AI_LLM_TIMEOUT = 30
+ parsed = backends.DSLQuery(query="rok = 2024", error=None)
+ fake_resp = mock.Mock()
+ fake_resp.parsed_output = parsed
+ fake_resp.stop_reason = "end_turn"
+ fake_resp.usage = mock.Mock(
+ input_tokens=100,
+ output_tokens=20,
+ cache_read_input_tokens=5,
+ cache_creation_input_tokens=7,
+ )
+ fake_client = mock.Mock()
+ fake_client.messages.parse.return_value = fake_resp
+ with mock.patch("anthropic.Anthropic", return_value=fake_client) as ctor:
+ result = backends.AnthropicBackend().call(
+ _system_blocks(), [{"role": "user", "content": "pytanie"}]
+ )
+ ctor.assert_called_once_with(timeout=30)
+ assert result.parsed is parsed
+ assert result.stop_reason == "end_turn"
+ assert result.usage == {
+ "input_tokens": 100,
+ "output_tokens": 20,
+ "cache_read_tokens": 5,
+ "cache_write_tokens": 7,
+ }
+ call_kwargs = fake_client.messages.parse.call_args.kwargs
+ assert call_kwargs["model"] == "claude-sonnet-5"
+ assert call_kwargs["thinking"] == {"type": "disabled"}
+ assert call_kwargs["max_tokens"] == 500
+ assert call_kwargs["output_format"] is backends.DSLQuery
+
+
+def _fake_openai_response(content: str, finish_reason="stop"):
+ resp = mock.Mock()
+ choice = mock.Mock()
+ choice.message.content = content
+ choice.finish_reason = finish_reason
+ resp.choices = [choice]
+ resp.usage = mock.Mock(prompt_tokens=50, completion_tokens=10)
+ return resp
+
+
+def test_openai_backend_call_parses_valid_json(settings):
+ settings.BPP_AI_BACKEND = "openai"
+ settings.BPP_AI_MODEL = "qwen3:8b"
+ settings.BPP_AI_BASE_URL = "http://localhost:11434/v1"
+ settings.BPP_AI_API_KEY = ""
+ settings.BPP_AI_LLM_TIMEOUT = 30
+
+ fake_resp = _fake_openai_response(
+ json.dumps({"query": "rok = 2024", "error": None})
+ )
+ fake_client = mock.Mock()
+ fake_client.chat.completions.create.return_value = fake_resp
+
+ with mock.patch("openai.OpenAI", return_value=fake_client) as ctor:
+ result = backends.OpenAICompatibleBackend().call(
+ _system_blocks(), [{"role": "user", "content": "publikacje z 2024"}]
+ )
+
+ ctor.assert_called_once_with(
+ base_url="http://localhost:11434/v1", api_key="sk-noauth", timeout=30
+ )
+ assert result.parsed.query == "rok = 2024"
+ assert result.parsed.error is None
+ assert result.stop_reason == "stop"
+ assert result.usage == {
+ "input_tokens": 50,
+ "output_tokens": 10,
+ "cache_read_tokens": 0,
+ "cache_write_tokens": 0,
+ }
+ create_kwargs = fake_client.chat.completions.create.call_args.kwargs
+ assert create_kwargs["model"] == "qwen3:8b"
+ assert create_kwargs["temperature"] == 0
+ assert create_kwargs["messages"][0] == {"role": "system", "content": "REGULY"}
+ assert create_kwargs["messages"][1] == {
+ "role": "user",
+ "content": "publikacje z 2024",
+ }
+ assert create_kwargs["response_format"]["json_schema"]["strict"] is True
+
+
+def test_openai_backend_call_flattens_multiple_system_blocks(settings):
+ settings.BPP_AI_BACKEND = "openai"
+ settings.BPP_AI_BASE_URL = "http://localhost:11434/v1"
+ settings.BPP_AI_API_KEY = ""
+ system = [
+ {"type": "text", "text": "CZESC1"},
+ {"type": "text", "text": "CZESC2", "cache_control": {"type": "ephemeral"}},
+ ]
+ fake_resp = _fake_openai_response(
+ json.dumps({"query": "rok = 2024", "error": None})
+ )
+ fake_client = mock.Mock()
+ fake_client.chat.completions.create.return_value = fake_resp
+
+ with mock.patch("openai.OpenAI", return_value=fake_client):
+ backends.OpenAICompatibleBackend().call(
+ system, [{"role": "user", "content": "x"}]
+ )
+
+ create_kwargs = fake_client.chat.completions.create.call_args.kwargs
+ assert create_kwargs["messages"][0]["content"] == "CZESC1\nCZESC2"
+
+
+def test_openai_backend_uses_api_key_when_set(settings):
+ settings.BPP_AI_BACKEND = "openai"
+ settings.BPP_AI_BASE_URL = "http://localhost:8000/v1"
+ settings.BPP_AI_API_KEY = "sk-real-key"
+ fake_resp = _fake_openai_response(
+ json.dumps({"query": "rok = 2024", "error": None})
+ )
+ fake_client = mock.Mock()
+ fake_client.chat.completions.create.return_value = fake_resp
+
+ with mock.patch("openai.OpenAI", return_value=fake_client) as ctor:
+ backends.OpenAICompatibleBackend().call(
+ _system_blocks(), [{"role": "user", "content": "x"}]
+ )
+
+ assert ctor.call_args.kwargs["api_key"] == "sk-real-key"
+
+
+@pytest.mark.parametrize("bad_content", ["nie-json", "{niepoprawny json", "[]"])
+def test_openai_backend_call_invalid_json_returns_meaningful_error(
+ settings, bad_content
+):
+ settings.BPP_AI_BACKEND = "openai"
+ settings.BPP_AI_BASE_URL = "http://localhost:11434/v1"
+ settings.BPP_AI_API_KEY = ""
+ fake_resp = _fake_openai_response(bad_content)
+ fake_client = mock.Mock()
+ fake_client.chat.completions.create.return_value = fake_resp
+
+ with mock.patch("openai.OpenAI", return_value=fake_client):
+ result = backends.OpenAICompatibleBackend().call(
+ _system_blocks(), [{"role": "user", "content": "x"}]
+ )
+
+ assert result.parsed.query is None
+ assert "niepoprawny JSON" in result.parsed.error
diff --git a/src/ai_search/tests/test_translator.py b/src/ai_search/tests/test_translator.py
index 35ed12661..d419bac7f 100644
--- a/src/ai_search/tests/test_translator.py
+++ b/src/ai_search/tests/test_translator.py
@@ -2,22 +2,22 @@
import pytest
-from ai_search import budget, translator
+from ai_search import backends, budget, translator
def _fake_response(query, error=None, usage=None):
- parsed = translator.DSLQuery(query=query, error=error)
- resp = mock.Mock()
- resp.parsed_output = parsed
- resp.stop_reason = "end_turn"
+ parsed = backends.DSLQuery(query=query, error=error)
u = usage or {}
- resp.usage = mock.Mock(
- input_tokens=u.get("input_tokens", 10),
- output_tokens=u.get("output_tokens", 5),
- cache_read_input_tokens=u.get("cache_read_tokens", 0),
- cache_creation_input_tokens=u.get("cache_write_tokens", 0),
+ return backends.LLMResult(
+ parsed=parsed,
+ usage={
+ "input_tokens": u.get("input_tokens", 10),
+ "output_tokens": u.get("output_tokens", 5),
+ "cache_read_tokens": u.get("cache_read_tokens", 0),
+ "cache_write_tokens": u.get("cache_write_tokens", 0),
+ },
+ stop_reason="end_turn",
)
- return resp
@pytest.fixture(autouse=True)
diff --git a/src/ai_search/tests/test_views.py b/src/ai_search/tests/test_views.py
index f4df4b692..28f3c747b 100644
--- a/src/ai_search/tests/test_views.py
+++ b/src/ai_search/tests/test_views.py
@@ -143,6 +143,40 @@ def test_post_pricing_keyerror_logs_zero_cost(staff_client, settings):
assert log.cost_pln == Decimal("0")
+@pytest.mark.django_db
+def test_post_openai_backend_skips_budget_and_logs_zero_cost(staff_client, settings):
+ """Backend lokalny (openai-compatible): brak pre-checku budżetu, brak
+ `budget_check` przekazanego do translatora, koszt/kurs zawsze 0 — bez
+ wołania pricing/fx (nieistotny cennik dla modelu lokalnego)."""
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "openai"
+ settings.BPP_AI_DAILY_BUDGET_PLN = "0" # budżet "wyczerpany" — ma być ignorowany
+ res = translator.TranslationResult(
+ query="rok = 2024", usage={"input_tokens": 10, "output_tokens": 5}, attempts=1
+ )
+ with (
+ mock.patch(
+ "ai_search.views.translator.translate", return_value=res
+ ) as translate_mock,
+ mock.patch("ai_search.views.pricing.cost_usd_from_usage") as pricing_mock,
+ mock.patch("ai_search.views.fx.usd_to_pln_rate") as fx_mock,
+ ):
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "publikacje z 2024"},
+ )
+ assert r.status_code == 302
+ assert reverse("bpp:zapytanie") in r.url
+ assert translate_mock.call_args.kwargs["budget_check"] is None
+ pricing_mock.assert_not_called()
+ fx_mock.assert_not_called()
+ log = AISearchQuery.objects.get()
+ assert log.success is True
+ assert log.cost_usd == Decimal("0")
+ assert log.fx_rate == Decimal("0")
+ assert log.cost_pln == Decimal("0")
+
+
@pytest.mark.django_db
def test_post_null_query_shows_error(staff_client, settings):
settings.BPP_AI_SEARCH_ENABLED = True
diff --git a/src/ai_search/translator.py b/src/ai_search/translator.py
index b7e858422..035632737 100644
--- a/src/ai_search/translator.py
+++ b/src/ai_search/translator.py
@@ -10,14 +10,12 @@
import logging
from dataclasses import dataclass, field
-import anthropic
from django.conf import settings
from django.core.exceptions import FieldError, ValidationError
from djangoql.exceptions import DjangoQLError
from djangoql.queryset import apply_search
-from pydantic import BaseModel, ConfigDict
-from ai_search import prompts, schema_export
+from ai_search import backends, prompts, schema_export
from bpp.djangoql_helpers import _error_location, _format_error_text
from bpp.djangoql_schema import BppQLSchema
from bpp.views.zapytanie import MODELS
@@ -25,14 +23,6 @@
logger = logging.getLogger(__name__)
-class DSLQuery(BaseModel):
- """Ustrukturyzowana odpowiedź modelu (``output_format``)."""
-
- model_config = ConfigDict(extra="forbid")
- query: str | None
- error: str | None
-
-
@dataclass
class TranslationResult:
query: str | None = None
@@ -56,30 +46,13 @@ def validate_query(query: str, model_key: str):
return _format_error_text(exc), loc
-def _client() -> anthropic.Anthropic:
- return anthropic.Anthropic(timeout=settings.BPP_AI_LLM_TIMEOUT)
-
-
-def _call_model(system, messages):
- """Pojedyncze wywołanie modelu (wydzielone dla testowalności)."""
- return _client().messages.parse(
- model=settings.BPP_AI_MODEL,
- max_tokens=500,
- thinking={"type": "disabled"},
- system=system,
- messages=messages,
- output_format=DSLQuery,
- )
-
+def _call_model(system, messages) -> backends.LLMResult:
+ """Pojedyncze wywołanie modelu (wydzielone dla testowalności).
-def _extract_usage(resp) -> dict:
- u = resp.usage
- return {
- "input_tokens": getattr(u, "input_tokens", 0) or 0,
- "output_tokens": getattr(u, "output_tokens", 0) or 0,
- "cache_read_tokens": getattr(u, "cache_read_input_tokens", 0) or 0,
- "cache_write_tokens": getattr(u, "cache_creation_input_tokens", 0) or 0,
- }
+ Deleguje do backendu wybranego przez ``settings.BPP_AI_BACKEND``
+ (``ai_search.backends.get_backend``) — natywny Anthropic albo dowolny
+ lokalny serwer zgodny z OpenAI Chat Completions API."""
+ return backends.get_backend().call(system, messages)
def _accumulate(total: dict, part: dict):
@@ -110,19 +83,23 @@ def translate(pytanie: str, model_key: str, budget_check=None) -> TranslationRes
result.budget_blocked = True
result.error = status.reason
result.query = None
+ # Blok następuje PRZED wykonaniem tej iteracji — jeśli został
+ # ustawiony przez poprzednią (udaną) iterację, nie jest już
+ # miarodajny: żadne wywołanie w TEJ iteracji się nie odbyło.
+ result.retried = False
return result
result.attempts = attempt + 1
- resp = _call_model(system, [{"role": "user", "content": content}])
- _accumulate(total_usage, _extract_usage(resp))
+ result_obj = _call_model(system, [{"role": "user", "content": content}])
+ _accumulate(total_usage, result_obj.usage)
result.usage = total_usage
- if getattr(resp, "stop_reason", None) == "refusal":
+ if result_obj.stop_reason == "refusal":
result.query = None
result.error = "Model odmówił odpowiedzi na to pytanie."
return result
- parsed = resp.parsed_output
+ parsed = result_obj.parsed
if parsed.query is None:
result.query = None
result.error = parsed.error or "Nie można wyrazić pytania w DSL."
diff --git a/src/ai_search/views.py b/src/ai_search/views.py
index 01d458118..5b282212a 100644
--- a/src/ai_search/views.py
+++ b/src/ai_search/views.py
@@ -32,16 +32,20 @@ def dispatch(self, request, *args, **kwargs):
def form_valid(self, form):
model_key = form.cleaned_data["model"]
pytanie = form.cleaned_data["pytanie"].strip()
+ is_anthropic = settings.BPP_AI_BACKEND == "anthropic"
- status = budget.check_budget()
- if not status.ok:
- return self.render_to_response(
- self.get_context_data(form=form, blad=status.reason)
- )
+ if is_anthropic:
+ status = budget.check_budget()
+ if not status.ok:
+ return self.render_to_response(
+ self.get_context_data(form=form, blad=status.reason)
+ )
try:
result = translator.translate(
- pytanie, model_key, budget_check=budget.check_budget
+ pytanie,
+ model_key,
+ budget_check=budget.check_budget if is_anthropic else None,
)
except Exception: # błędy SDK/sieci — log + generyczny komunikat
rollbar.report_exc_info()
@@ -78,16 +82,21 @@ def form_valid(self, form):
)
def _log(self, result, model_key, pytanie):
- rate = fx.usd_to_pln_rate()
- try:
- cost_usd = pricing.cost_usd_from_usage(
- result.usage, settings.BPP_AI_MODEL, date.today()
- )
- except KeyError:
- rollbar.report_exc_info()
- logger.error(
- "Brak ceny dla modelu %s — koszt nieznany", settings.BPP_AI_MODEL
- )
+ if settings.BPP_AI_BACKEND == "anthropic":
+ rate = fx.usd_to_pln_rate()
+ try:
+ cost_usd = pricing.cost_usd_from_usage(
+ result.usage, settings.BPP_AI_MODEL, date.today()
+ )
+ except KeyError:
+ rollbar.report_exc_info()
+ logger.error(
+ "Brak ceny dla modelu %s — koszt nieznany", settings.BPP_AI_MODEL
+ )
+ cost_usd = Decimal("0")
+ else:
+ # Backend lokalny (openai-compatible) — darmowy, brak cennika/FX.
+ rate = Decimal("0")
cost_usd = Decimal("0")
AISearchQuery.objects.create(
user=self.request.user if self.request.user.is_authenticated else None,
diff --git a/src/django_bpp/settings/base.py b/src/django_bpp/settings/base.py
index 80ff6b923..14a411ca2 100644
--- a/src/django_bpp/settings/base.py
+++ b/src/django_bpp/settings/base.py
@@ -1872,6 +1872,15 @@ def iter_namespace(ns_pkg):
# --- Wyszukiwanie przez AI (ai_search) ---
BPP_AI_SEARCH_ENABLED = env("BPP_AI_SEARCH_ENABLED", default=False, cast=bool)
BPP_AI_MODEL = env("BPP_AI_MODEL", default="claude-sonnet-5")
+# Backend LLM: "anthropic" (natywny SDK, domyślny, płatny, budżet PLN) albo
+# "openai" (lokalny/self-hosted serwer OpenAI-compatible — Ollama, llama.cpp,
+# vLLM, LM Studio, LocalAI — darmowy, budżet nieaktywny).
+BPP_AI_BACKEND = env("BPP_AI_BACKEND", default="anthropic")
+# Dla backendu "openai": adres API zgodny z OpenAI (np.
+# http://localhost:11434/v1 dla Ollama).
+BPP_AI_BASE_URL = env("BPP_AI_BASE_URL", default="")
+# Dla backendu "openai": klucz API (puste dla serwerów bez auth, np. Ollama).
+BPP_AI_API_KEY = env("BPP_AI_API_KEY", default="")
BPP_AI_DAILY_BUDGET_PLN = env("BPP_AI_DAILY_BUDGET_PLN", default="20", cast=str)
BPP_AI_MONTHLY_BUDGET_PLN = env("BPP_AI_MONTHLY_BUDGET_PLN", default="300", cast=str)
BPP_AI_MAX_RETRIES = env("BPP_AI_MAX_RETRIES", default=1, cast=int)
diff --git a/uv.lock b/uv.lock
index 01da1ed72..6b064e6af 100644
--- a/uv.lock
+++ b/uv.lock
@@ -458,6 +458,7 @@ dependencies = [
{ name = "mozilla-django-oidc", marker = "platform_python_implementation != 'PyPy'" },
{ name = "nh3", marker = "platform_python_implementation != 'PyPy'" },
{ name = "numpy", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "openai", marker = "platform_python_implementation != 'PyPy'" },
{ name = "openpyxl", marker = "platform_python_implementation != 'PyPy'" },
{ name = "ortools", marker = "platform_python_implementation != 'PyPy'" },
{ name = "pandas", marker = "platform_python_implementation != 'PyPy'" },
@@ -643,6 +644,7 @@ requires-dist = [
{ name = "mozilla-django-oidc", specifier = ">=5.0.2,<6" },
{ name = "nh3", specifier = ">=0.3.6" },
{ name = "numpy", specifier = ">=2.4.6" },
+ { name = "openai", specifier = ">=1.50" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "ortools", specifier = ">=9.15.6755" },
{ name = "pandas", specifier = ">=3.0.3" },
@@ -3790,6 +3792,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
+[[package]]
+name = "openai"
+version = "2.44.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "distro", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "httpx", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jiter", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pydantic", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "sniffio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "tqdm", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" },
+]
+
[[package]]
name = "openpyxl"
version = "3.1.5"
From 3bec2d9067989cf6cbb7f79df7d61aa3db49898a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Sun, 5 Jul 2026 20:27:43 +0200
Subject: [PATCH 21/27] fix(ai-search): jedno zrodlo prawdy wyboru backendu
(anty-literowka) + docstring/docs/config-guard
Literowka w BPP_AI_BACKEND (np. "Anthropic") kierowala get_backend() na
platne Anthropic API, podczas gdy views.py (por. do settings.BPP_AI_BACKEND
== "anthropic") traktowal ja jak backend darmowy - pomijal budzet i logowal
koszt 0. Dodaje backends.active_backend_name() jako jedyne zrodlo prawdy
(uzywane przez get_backend() i views.py) - nieznana wartosc trafia zawsze
na bezpieczna sciezke anthropic+budzet, nigdy na darmowa-sciezke z realnym
wydatkiem. Plus: aktualny docstring translatora, ostrzezenie w docs o
trybie "thinking" Qwen3 (ucina JSON przy max_tokens=500) i czytelny
ImproperlyConfigured gdy backend openai bez BPP_AI_BASE_URL.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/deweloper/ai-search.md | 8 +++++
src/ai_search/backends.py | 25 +++++++++++++--
src/ai_search/tests/test_backends.py | 25 +++++++++++++++
src/ai_search/tests/test_views.py | 47 ++++++++++++++++++++++++++++
src/ai_search/translator.py | 13 +++++---
src/ai_search/views.py | 6 ++--
6 files changed, 114 insertions(+), 10 deletions(-)
diff --git a/docs/deweloper/ai-search.md b/docs/deweloper/ai-search.md
index dfcfe2bb7..30d6b81ba 100644
--- a/docs/deweloper/ai-search.md
+++ b/docs/deweloper/ai-search.md
@@ -156,6 +156,14 @@ wyjściowy jest już w tokenach angielskich/DjangoQL):
- **Qwen3** — rekomendacja główna (najlepszy stosunek JSON+PL wśród
modeli tej klasy). **4B/8B** dla lekkich instalacji, **14B/32B** dla
lepszej trafności na pytaniach dwuznacznych po polsku.
+ **Uwaga:** Qwen3 domyślnie ma włączony tryb „thinking" — potrafi
+ wygenerować blok rozumowania PRZED właściwym JSON-em, co przy stałym
+ `max_tokens=500` (patrz `backends.py`) grozi ucięciem odpowiedzi zanim
+ dojdzie do samego JSON-a. Wyłącz myślenie (np. prompt kończący się
+ `/no_think` albo odpowiednia flaga serwera inferencji). Walidator
+ DjangoQL + bounded-retry i tak wyłapią taki przypadek (zwrócony
+ niepoprawny JSON), ale lepiej uniknąć strat tokenów/czasu i wyłączyć
+ myślenie u źródła.
- **Llama 3.1 8B** (128k ctx), **Gemma 2 9B**, **Mistral** — alternatywy,
jeśli Qwen3 nie jest dostępny lub preferowany.
- Dla twardej gwarancji poprawnej składni JSON: **llama.cpp z GBNF**
diff --git a/src/ai_search/backends.py b/src/ai_search/backends.py
index f13679548..5b5acf6cd 100644
--- a/src/ai_search/backends.py
+++ b/src/ai_search/backends.py
@@ -23,6 +23,7 @@
import anthropic
from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
from pydantic import BaseModel, ConfigDict, ValidationError
logger = logging.getLogger(__name__)
@@ -82,6 +83,13 @@ class OpenAICompatibleBackend:
def _client(self):
from openai import OpenAI
+ if not settings.BPP_AI_BASE_URL:
+ raise ImproperlyConfigured(
+ "Backend AI ustawiony na 'openai' (BPP_AI_BACKEND), ale "
+ "BPP_AI_BASE_URL jest puste — ustaw BPP_AI_BASE_URL na adres "
+ "lokalnego serwera zgodnego z OpenAI Chat Completions API "
+ "(np. http://localhost:11434/v1 dla Ollama)."
+ )
return OpenAI(
base_url=settings.BPP_AI_BASE_URL,
api_key=settings.BPP_AI_API_KEY or "sk-noauth",
@@ -135,8 +143,21 @@ def call(self, system, messages) -> LLMResult:
)
+def active_backend_name() -> str:
+ """Znormalizowana nazwa backendu: ``"openai"`` tylko dla dokładnie
+ ``"openai"``; wszystko inne (w tym literówki typu ``"Anthropic"``,
+ ``" anthropic"``, wartości puste/nieznane) -> ``"anthropic"``.
+
+ To jest JEDYNE źródło prawdy o wyborze backendu — używane zarówno przez
+ ``get_backend()`` (poniżej), jak i przez ``views.py`` przy decyzji o
+ zastosowaniu budżetu/kosztu. Bezpieczny default: niejednoznaczna wartość
+ trafia na płatną ścieżkę OBJĘTĄ budżetem, nigdy na darmową-ścieżkę z
+ realnym (niekontrolowanym) wydatkiem."""
+ return "openai" if settings.BPP_AI_BACKEND == "openai" else "anthropic"
+
+
def get_backend():
- """Wybiera backend wg ``settings.BPP_AI_BACKEND`` (domyślnie anthropic)."""
- if settings.BPP_AI_BACKEND == "openai":
+ """Wybiera backend wg ``active_backend_name()`` (domyślnie anthropic)."""
+ if active_backend_name() == "openai":
return OpenAICompatibleBackend()
return AnthropicBackend()
diff --git a/src/ai_search/tests/test_backends.py b/src/ai_search/tests/test_backends.py
index f8846e45e..1cfb6cdf6 100644
--- a/src/ai_search/tests/test_backends.py
+++ b/src/ai_search/tests/test_backends.py
@@ -2,6 +2,7 @@
from unittest import mock
import pytest
+from django.core.exceptions import ImproperlyConfigured
from ai_search import backends
@@ -21,6 +22,21 @@ def test_get_backend_unknown_falls_back_to_anthropic(settings):
assert isinstance(backends.get_backend(), backends.AnthropicBackend)
+def test_active_backend_name_openai_exact(settings):
+ settings.BPP_AI_BACKEND = "openai"
+ assert backends.active_backend_name() == "openai"
+
+
+@pytest.mark.parametrize(
+ "value", ["Anthropic", " anthropic", "claude", "cos-nieznanego", ""]
+)
+def test_active_backend_name_anything_else_is_anthropic(settings, value):
+ """Literówka lub nieznana wartość MUSI trafić na bezpieczną (płatną +
+ budżetowaną) ścieżkę anthropic, nigdy na darmową openai-ścieżkę."""
+ settings.BPP_AI_BACKEND = value
+ assert backends.active_backend_name() == "anthropic"
+
+
def _system_blocks():
return [{"type": "text", "text": "REGULY", "cache_control": {"type": "ephemeral"}}]
@@ -134,6 +150,15 @@ def test_openai_backend_call_flattens_multiple_system_blocks(settings):
assert create_kwargs["messages"][0]["content"] == "CZESC1\nCZESC2"
+def test_openai_backend_empty_base_url_raises_clear_config_error(settings):
+ settings.BPP_AI_BACKEND = "openai"
+ settings.BPP_AI_BASE_URL = ""
+ with pytest.raises(ImproperlyConfigured, match="BPP_AI_BASE_URL"):
+ backends.OpenAICompatibleBackend().call(
+ _system_blocks(), [{"role": "user", "content": "x"}]
+ )
+
+
def test_openai_backend_uses_api_key_when_set(settings):
settings.BPP_AI_BACKEND = "openai"
settings.BPP_AI_BASE_URL = "http://localhost:8000/v1"
diff --git a/src/ai_search/tests/test_views.py b/src/ai_search/tests/test_views.py
index 28f3c747b..53a99a5c2 100644
--- a/src/ai_search/tests/test_views.py
+++ b/src/ai_search/tests/test_views.py
@@ -177,6 +177,53 @@ def test_post_openai_backend_skips_budget_and_logs_zero_cost(staff_client, setti
assert log.cost_pln == Decimal("0")
+@pytest.mark.django_db
+def test_post_miscased_backend_is_treated_as_anthropic_budget_applies(
+ staff_client, settings
+):
+ """Literówka w BPP_AI_BACKEND (np. 'Anthropic' zamiast 'anthropic') NIE
+ MOŻE ominąć budżetu — to jest sedno FIX 1: `get_backend()` i widok mają
+ być zgodne, więc dla dowolnej wartości ≠ dokładnie 'openai' widok musi
+ zablokować zapytanie pre-checkiem budżetu, dokładnie jak dla
+ 'anthropic' (patrz test_post_blocked_by_budget)."""
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "Anthropic" # zła wielkość liter - literówka
+ settings.BPP_AI_DAILY_BUDGET_PLN = "0" # budżet "wyczerpany"
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "cokolwiek"},
+ )
+ assert r.status_code == 200
+ assert "limit" in r.content.decode().lower()
+ assert AISearchQuery.objects.count() == 0
+
+
+@pytest.mark.django_db
+def test_post_miscased_backend_logs_real_cost_not_zero(staff_client, settings):
+ """Dla tej samej literówki, gdy budżet NIE jest wyczerpany: koszt/kurs
+ muszą być liczone tak jak dla 'anthropic' (niezerowe), a nie potraktowane
+ jak darmowy backend lokalny (por. test_post_openai_backend_skips_budget_
+ and_logs_zero_cost, gdzie cost_pln == 0)."""
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "Anthropic" # zła wielkość liter - literówka
+ res = translator.TranslationResult(
+ query="rok = 2024", usage={"input_tokens": 10, "output_tokens": 5}, attempts=1
+ )
+ with (
+ mock.patch("ai_search.views.translator.translate", return_value=res) as tr,
+ mock.patch("ai_search.views.fx.usd_to_pln_rate", return_value=Decimal("4.1")),
+ ):
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "publikacje z 2024"},
+ )
+ assert r.status_code == 302
+ assert tr.call_args.kwargs["budget_check"] is not None
+ log = AISearchQuery.objects.get()
+ assert log.success is True
+ assert log.cost_pln > 0
+
+
@pytest.mark.django_db
def test_post_null_query_shows_error(staff_client, settings):
settings.BPP_AI_SEARCH_ENABLED = True
diff --git a/src/ai_search/translator.py b/src/ai_search/translator.py
index 035632737..48f4551d5 100644
--- a/src/ai_search/translator.py
+++ b/src/ai_search/translator.py
@@ -1,10 +1,13 @@
"""Tłumacz pytań w języku polskim na zapytania DjangoQL (LLM + walidacja).
-Wywołuje model (SDK ``anthropic``, ``messages.parse`` z ustrukturyzowanym
-``output_format=DSLQuery``), waliduje zwrócone zapytanie realnym parserem
-DjangoQL (``apply_search`` + ``BppQLSchema``) i — jeśli składnia jest zła —
-ponawia próbę, przekazując modelowi dokładny komunikat błędu (linia/kolumna),
-do ``settings.BPP_AI_MAX_RETRIES`` razy.
+Wywołuje model przez ``ai_search.backends.get_backend()`` — natywny SDK
+``anthropic`` (``messages.parse`` z ustrukturyzowanym ``output_format=
+DSLQuery``) albo dowolny lokalny serwer zgodny z OpenAI Chat Completions API
+(backend wybierany przez ``ai_search.backends.active_backend_name()``, patrz
+``backends.py``) — waliduje zwrócone zapytanie realnym parserem DjangoQL
+(``apply_search`` + ``BppQLSchema``) i — jeśli składnia jest zła — ponawia
+próbę, przekazując modelowi dokładny komunikat błędu (linia/kolumna), do
+``settings.BPP_AI_MAX_RETRIES`` razy.
"""
import logging
diff --git a/src/ai_search/views.py b/src/ai_search/views.py
index 5b282212a..23ac9612b 100644
--- a/src/ai_search/views.py
+++ b/src/ai_search/views.py
@@ -9,7 +9,7 @@
from django.urls import reverse
from django.views.generic import FormView
-from ai_search import budget, fx, pricing, translator
+from ai_search import backends, budget, fx, pricing, translator
from ai_search.forms import AISearchForm
from ai_search.models import AISearchQuery
from bpp.views.zapytanie import WprowadzanieDanychOrSuperuserMixin
@@ -32,7 +32,7 @@ def dispatch(self, request, *args, **kwargs):
def form_valid(self, form):
model_key = form.cleaned_data["model"]
pytanie = form.cleaned_data["pytanie"].strip()
- is_anthropic = settings.BPP_AI_BACKEND == "anthropic"
+ is_anthropic = backends.active_backend_name() == "anthropic"
if is_anthropic:
status = budget.check_budget()
@@ -82,7 +82,7 @@ def form_valid(self, form):
)
def _log(self, result, model_key, pytanie):
- if settings.BPP_AI_BACKEND == "anthropic":
+ if backends.active_backend_name() == "anthropic":
rate = fx.usd_to_pln_rate()
try:
cost_usd = pricing.cost_usd_from_usage(
From cb67626931ccaa8add62b277f7d52343966ecc93 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Sun, 5 Jul 2026 20:51:37 +0200
Subject: [PATCH 22/27] fix(ai-search): placeholder api_key 'not-needed'
zamiast 'sk-noauth' (false-positive GitGuardian)
Prefiks 'sk-' wygladal jak klucz OpenAI i wywalal secret-scan; to tylko
placeholder dla lokalnych serwerow OpenAI-compatible ktore nie wymagaja auth.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/ai_search/backends.py | 2 +-
src/ai_search/tests/test_backends.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/ai_search/backends.py b/src/ai_search/backends.py
index 5b5acf6cd..8d485a294 100644
--- a/src/ai_search/backends.py
+++ b/src/ai_search/backends.py
@@ -92,7 +92,7 @@ def _client(self):
)
return OpenAI(
base_url=settings.BPP_AI_BASE_URL,
- api_key=settings.BPP_AI_API_KEY or "sk-noauth",
+ api_key=settings.BPP_AI_API_KEY or "not-needed",
timeout=settings.BPP_AI_LLM_TIMEOUT,
)
diff --git a/src/ai_search/tests/test_backends.py b/src/ai_search/tests/test_backends.py
index 1cfb6cdf6..c215ff717 100644
--- a/src/ai_search/tests/test_backends.py
+++ b/src/ai_search/tests/test_backends.py
@@ -105,7 +105,7 @@ def test_openai_backend_call_parses_valid_json(settings):
)
ctor.assert_called_once_with(
- base_url="http://localhost:11434/v1", api_key="sk-noauth", timeout=30
+ base_url="http://localhost:11434/v1", api_key="not-needed", timeout=30
)
assert result.parsed.query == "rok = 2024"
assert result.parsed.error is None
From 937df1a736ea91f13cf975a12cd3479ef86543b7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Sun, 5 Jul 2026 22:13:35 +0200
Subject: [PATCH 23/27] fix(ai-search): wnioski z code review PR #451
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- fx.usd_to_pln_rate: zdeformowana odpowiedź NBP z HTTP 200 (puste
"rates" → IndexError, mid=null → decimal.InvalidOperation, rates
nie-listą → TypeError) uciekała z except i łamała kontrakt „Nigdy
nie podnosi wyjątku", wywalając request już po opłaconym wywołaniu
LLM; poszerzono tuple o LookupError/TypeError/ArithmeticError
+ 3 testy regresyjne (TDD red→green)
- views._log: date.today() → timezone.localdate() przy wyborze tieru
cenowego (spójnie z budget.py, poprawny dzień przy TZ != Warszawa)
- translator: docstring modułu opisuje twardy limit
min(BPP_AI_MAX_RETRIES, 2) zamiast sugerować brak capu
- test_graph_significantly_smaller: docstring poprawiony na stan
faktyczny — 86 modeli (bpp + pbn_api + taggit), pbn_export_queue
nieosiągalne
Co-Authored-By: Claude Fable 5
---
src/ai_search/fx.py | 12 ++++-
src/ai_search/tests/test_fx.py | 44 +++++++++++++++++++
src/ai_search/translator.py | 5 ++-
src/ai_search/views.py | 4 +-
.../tests/test_djangoql_schema_hardening.py | 11 ++---
5 files changed, 66 insertions(+), 10 deletions(-)
diff --git a/src/ai_search/fx.py b/src/ai_search/fx.py
index cfde93630..0fc2b83ef 100644
--- a/src/ai_search/fx.py
+++ b/src/ai_search/fx.py
@@ -35,7 +35,17 @@ def usd_to_pln_rate() -> Decimal:
cache.set(_CACHE_KEY, str(rate), settings.BPP_AI_FX_CACHE_TTL)
FxRate.store(rate)
return rate
- except (requests.RequestException, OSError, KeyError, ValueError) as exc:
+ except (
+ requests.RequestException,
+ OSError,
+ LookupError,
+ TypeError,
+ ValueError,
+ ArithmeticError,
+ ) as exc:
+ # LookupError = KeyError + IndexError (brakujące/puste "rates"),
+ # ArithmeticError = decimal.InvalidOperation (mid=null/nieliczbowe),
+ # TypeError = nie-lista/skalar w JSON — wszystko degraduje do fallbacku.
logger.warning("NBP FX niedostępny (%s), używam fallbacku", exc)
last = FxRate.latest()
diff --git a/src/ai_search/tests/test_fx.py b/src/ai_search/tests/test_fx.py
index d43bbecb8..83a830433 100644
--- a/src/ai_search/tests/test_fx.py
+++ b/src/ai_search/tests/test_fx.py
@@ -34,6 +34,14 @@ def _nbp_response(mid):
return m
+def _raw_response(payload):
+ """HTTP 200 z dowolnym (potencjalnie zniekształconym) ciałem JSON."""
+ m = mock.Mock()
+ m.raise_for_status = mock.Mock()
+ m.json.return_value = payload
+ return m
+
+
@pytest.mark.django_db
def test_fetches_from_nbp_and_persists():
with mock.patch("ai_search.fx.requests.get", return_value=_nbp_response(4.11)):
@@ -64,3 +72,39 @@ def test_terminal_fallback_when_nothing_available(settings):
with mock.patch("ai_search.fx.requests.get", side_effect=OSError("boom")):
rate = fx.usd_to_pln_rate()
assert rate == Decimal("4.5")
+
+
+@pytest.mark.django_db
+def test_empty_rates_list_falls_back_without_raising():
+ """HTTP 200 z pustą listą ``rates`` (IndexError) nie może uciec —
+ docstring gwarantuje „Nigdy nie podnosi wyjątku”."""
+ FxRate.store("4.02")
+ with mock.patch(
+ "ai_search.fx.requests.get", return_value=_raw_response({"rates": []})
+ ):
+ rate = fx.usd_to_pln_rate()
+ assert rate == Decimal("4.0200")
+
+
+@pytest.mark.django_db
+def test_null_mid_falls_back_without_raising():
+ """HTTP 200 z ``mid=null`` → ``Decimal("None")`` (decimal.InvalidOperation,
+ podklasa ArithmeticError, NIE ValueError) nie może uciec."""
+ FxRate.store("4.03")
+ with mock.patch(
+ "ai_search.fx.requests.get",
+ return_value=_raw_response({"rates": [{"mid": None}]}),
+ ):
+ rate = fx.usd_to_pln_rate()
+ assert rate == Decimal("4.0300")
+
+
+@pytest.mark.django_db
+def test_rates_not_a_list_falls_back_without_raising(settings):
+ """HTTP 200 z ``rates=null`` → ``None[0]`` (TypeError) nie może uciec."""
+ settings.BPP_AI_FX_FALLBACK = "4.44"
+ with mock.patch(
+ "ai_search.fx.requests.get", return_value=_raw_response({"rates": None})
+ ):
+ rate = fx.usd_to_pln_rate()
+ assert rate == Decimal("4.44")
diff --git a/src/ai_search/translator.py b/src/ai_search/translator.py
index 48f4551d5..10d311752 100644
--- a/src/ai_search/translator.py
+++ b/src/ai_search/translator.py
@@ -6,8 +6,9 @@
(backend wybierany przez ``ai_search.backends.active_backend_name()``, patrz
``backends.py``) — waliduje zwrócone zapytanie realnym parserem DjangoQL
(``apply_search`` + ``BppQLSchema``) i — jeśli składnia jest zła — ponawia
-próbę, przekazując modelowi dokładny komunikat błędu (linia/kolumna), do
-``settings.BPP_AI_MAX_RETRIES`` razy.
+próbę, przekazując modelowi dokładny komunikat błędu (linia/kolumna),
+maksymalnie ``min(settings.BPP_AI_MAX_RETRIES, 2)`` razy — twardy limit 2
+ponowień, niezależnie od ustawienia.
"""
import logging
diff --git a/src/ai_search/views.py b/src/ai_search/views.py
index 23ac9612b..882c61613 100644
--- a/src/ai_search/views.py
+++ b/src/ai_search/views.py
@@ -1,5 +1,4 @@
import logging
-from datetime import date
from decimal import Decimal
from urllib.parse import urlencode
@@ -7,6 +6,7 @@
from django.conf import settings
from django.http import Http404, HttpResponseRedirect
from django.urls import reverse
+from django.utils import timezone
from django.views.generic import FormView
from ai_search import backends, budget, fx, pricing, translator
@@ -86,7 +86,7 @@ def _log(self, result, model_key, pytanie):
rate = fx.usd_to_pln_rate()
try:
cost_usd = pricing.cost_usd_from_usage(
- result.usage, settings.BPP_AI_MODEL, date.today()
+ result.usage, settings.BPP_AI_MODEL, timezone.localdate()
)
except KeyError:
rollbar.report_exc_info()
diff --git a/src/bpp/tests/test_djangoql_schema_hardening.py b/src/bpp/tests/test_djangoql_schema_hardening.py
index f0ff8059a..100c4f104 100644
--- a/src/bpp/tests/test_djangoql_schema_hardening.py
+++ b/src/bpp/tests/test_djangoql_schema_hardening.py
@@ -140,10 +140,11 @@ def test_bppuser_values_never_emitted():
def test_graph_significantly_smaller():
"""Graf modeli mocno się kurczy po wykluczeniu aplikacji roboczych.
- Przed uszczelnieniem: 216 modeli (BFS z Rekord). Po: 87 (bpp + pbn_api +
- pbn_export_queue + taggit — żadna z wykluczonych app_labels). Próg < 100
- zamiast przykładowego < 60 z briefu, bo `pbn_api` (celowo NIE wykluczone,
- niesie `pbn_uid`) oraz jego własne relacje (Publication, Institution,
- Scientist, Journal, …) dociągają część grafu z powrotem — patrz raport."""
+ Przed uszczelnieniem: 216 modeli (BFS z Rekord). Po: 86 (bpp + pbn_api +
+ taggit — żadna z wykluczonych app_labels; `pbn_export_queue` NIE jest
+ osiągalne w grafie). Próg < 100 zamiast przykładowego < 60 z briefu, bo
+ `pbn_api` (celowo NIE wykluczone, niesie `pbn_uid`) oraz jego własne relacje
+ (Publication, Institution, Scientist, Journal, …) dociągają część grafu z
+ powrotem — patrz raport."""
schema = BppQLSchema(Rekord)
assert len(schema.models) < 100
From 1c85f8bd412b88f7ec3f98b0971ddca5ad3dfe44 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Tue, 14 Jul 2026 22:53:39 +0200
Subject: [PATCH 24/27] chore(ai-search): reconcyliacja schematu LLM po rebase
na dev
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Po rebase feat/ai-search na origin/dev (dev niezaleznie zbudowal wlasny
stack schematu LLM: RekordLLMSchema + BppQLSchemaOgraniczony + agent-facing
API api_v1 + komenda opisz_schemat_djangoql_dla_llm + serwer MCP):
- schema_export.py: BppQLSchema -> RekordLLMSchema. Bazowa BppQLSchema w dev
jest teraz PELNA (dla adminow, bez uszczelnienia) — karmienie jej do
describe_schema_for_llm wyciekloby modele/pola wrazliwe (bppuser.password)
do promptu. RekordLLMSchema to kanoniczny schemat agent-facing z dev
(allow-lista rdzenia bibliograficznego + blocklista PII + osadzanie
wartosci tylko dla bezpiecznych slownikow) — ten sam, ktorego uzywa
DEFAULT_SCHEMA komendy opisz_schemat_djangoql_dla_llm.
- usunieto test_djangoql_schema_hardening.py: testowal uszczelnienie
BAZOWEJ BppQLSchema (podejscie z tej galezi, zastapione w dev). Dev ma
test_djangoql_schema_llm.py pokrywajacy te same wlasnosci bezpieczenstwa
(traversal do password_policies zamkniety, bppuser obciety, sekrety
uczelni nieobecne) na RekordLLMSchema/BppQLSchemaOgraniczony.
- uv.lock: regeneracja na bazie dev (anthropic, openai + zaleznosci).
Testy: src/ai_search/ + test_djangoql_schema_llm.py => 95 passed, 3 skipped.
---
src/ai_search/schema_export.py | 13 +-
.../tests/test_djangoql_schema_hardening.py | 150 ---------
uv.lock | 297 +++++++++++++++++-
3 files changed, 304 insertions(+), 156 deletions(-)
delete mode 100644 src/bpp/tests/test_djangoql_schema_hardening.py
diff --git a/src/ai_search/schema_export.py b/src/ai_search/schema_export.py
index 030df722b..78095f389 100644
--- a/src/ai_search/schema_export.py
+++ b/src/ai_search/schema_export.py
@@ -2,7 +2,7 @@
from django.core.cache import cache
from djangoql.llm import describe_schema_for_llm
-from bpp.djangoql_schema import BppQLSchema
+from bpp.djangoql_schema import RekordLLMSchema
from bpp.views.zapytanie import MODELS
@@ -16,10 +16,17 @@ def _build(model_key: str) -> str:
Podnosi KeyError dla nieznanego klucza. describe_schema_for_llm sięga bazy
dla pól z suggest_options — dlatego wynik jest cache'owany. Zwraca
zwarty (compact), samodokumentujący się string, gotowy do wstrzyknięcia
- do promptu LLM."""
+ do promptu LLM.
+
+ Używa ``RekordLLMSchema`` (kanoniczny, agent-facing schemat LLM z gałęzi
+ dev: allow-lista rdzenia bibliograficznego + blocklista PII + osadzanie
+ wartości tylko dla bezpiecznych słowników). To ten sam schemat, którego
+ używa komenda ``opisz_schemat_djangoql_dla_llm`` oraz API agenta
+ ``api_v1``. NIE bazowa ``BppQLSchema`` — ta jest pełna (dla adminów) i
+ wyciekłaby modele/pola wrażliwe (np. ``bppuser.password``) do promptu."""
model = MODELS[model_key]
return describe_schema_for_llm(
- BppQLSchema(model),
+ RekordLLMSchema(model),
format="compact",
max_fk_options=settings.BPP_AI_MAX_FK_OPTIONS,
)
diff --git a/src/bpp/tests/test_djangoql_schema_hardening.py b/src/bpp/tests/test_djangoql_schema_hardening.py
deleted file mode 100644
index 100c4f104..000000000
--- a/src/bpp/tests/test_djangoql_schema_hardening.py
+++ /dev/null
@@ -1,150 +0,0 @@
-"""Testy uszczelnienia współdzielonej ``BppQLSchema`` (DjangoQL).
-
-Patrz docstring modułu ``bpp.djangoql_schema`` oraz brief zadania:
-wyciek `autorzy.autor.user.password_change_required` (traversal do
-`password_policies` przez `BppUser`) musi przestać być wyrażalny, a graf
-modeli (BFS z `Rekord`) musi się odchudzić o aplikacje robocze/wrażliwe.
-"""
-
-import pytest
-from djangoql.exceptions import DjangoQLError
-from djangoql.queryset import apply_search
-
-from bpp.djangoql_schema import BppQLSchema
-from bpp.models import Rekord
-
-
-@pytest.mark.django_db
-def test_password_change_required_traversal_closed():
- """Wyciek zamknięty: nie da się dojść do password_policies przez BppUser."""
- with pytest.raises(DjangoQLError):
- apply_search(
- Rekord.objects.all(),
- "autorzy.autor.user.password_change_required != None",
- schema=BppQLSchema,
- )
-
-
-@pytest.mark.django_db
-def test_bppuser_fields_truncated_to_allowlist():
- """BppUser: tylko username/nazwisko/imiona — bez hasła/uprawnień/emaila."""
- schema = BppQLSchema(Rekord)
- label = "bpp.bppuser"
- if label not in schema.models:
- pytest.skip("bpp.bppuser nieosiągalny w grafie — allowlista bez znaczenia")
- fields = set(schema.models[label])
- assert fields <= {"username", "nazwisko", "imiona"}
- for sensitive in ("password", "is_superuser", "email", "pbn_token"):
- assert sensitive not in fields
-
-
-@pytest.mark.django_db
-def test_uczelnia_fields_truncated_to_allowlist():
- """Uczelnia: tylko id/nazwa/skrot/pbn_uid (± picker pbn_uid__rel) — bez
- pól konfiguracyjnych (integracje, hasła zewnętrznych API, ustawienia UI)."""
- schema = BppQLSchema(Rekord)
- label = "bpp.uczelnia"
- if label not in schema.models:
- pytest.skip("bpp.uczelnia nieosiągalny w grafie — allowlista bez znaczenia")
- fields = set(schema.models[label])
- allowed = {"id", "nazwa", "skrot", "pbn_uid"}
- assert fields <= allowed | {f"{name}__rel" for name in allowed}
- for config_field in (
- "pbn_integracja",
- "wyszukiwanie_rekordy_na_strone_anonim",
- "pbn_api_user",
- "clarivate_password",
- "dspace_api_password",
- "orcid_client_secret",
- ):
- assert config_field not in fields
-
-
-@pytest.mark.django_db
-def test_uczelnia_picker_does_not_bypass_filter():
- """Pickery ``__rel`` nie omijają include_fields: żaden picker uczelni
- nie wskazuje na pole spoza allowlisty (np. relacji config-owej)."""
- schema = BppQLSchema(Rekord)
- label = "bpp.uczelnia"
- if label not in schema.models:
- pytest.skip("bpp.uczelnia nieosiągalny w grafie — test bez znaczenia")
- fields = set(schema.models[label])
- allowed_base = {"id", "nazwa", "skrot", "pbn_uid"}
- for name in fields:
- if name.endswith("__rel"):
- base = name[: -len("__rel")]
- assert base in allowed_base, (
- f"picker {name!r} wskazuje na pole spoza include_fields "
- f"({base!r} nie jest w {allowed_base!r}) — filtr obejdziony"
- )
-
-
-@pytest.mark.django_db
-def test_excluded_model_absent_from_graph():
- """Model z twardej listy wykluczeń (easyaudit) nie występuje w grafie."""
- schema = BppQLSchema(Rekord)
- assert "easyaudit.crudevent" not in schema.models
-
-
-@pytest.mark.django_db
-@pytest.mark.parametrize(
- "query",
- [
- "rok = 2024",
- 'autorzy.autor.nazwisko ~ "Kowalski"',
- 'charakter_formalny.skrot = "AC"',
- 'typ_kbn.skrot = "PO"',
- 'zrodlo.nazwa ~ "x"',
- "pbn_uid != None",
- ],
-)
-def test_core_queries_still_parse(query):
- """Rdzeń funkcjonalny (edytor/admin) musi dalej działać po uszczelnieniu."""
- apply_search(Rekord.objects.all(), query, schema=BppQLSchema)
-
-
-@pytest.mark.django_db
-def test_dictionary_models_remain_in_graph():
- """Słowniki (charakter_formalny, typ_kbn) zostają w grafie."""
- schema = BppQLSchema(Rekord)
- assert "bpp.charakter_formalny" in schema.models
- assert "bpp.typ_kbn" in schema.models
-
-
-@pytest.mark.django_db
-def test_bppuser_values_never_emitted():
- """describe_schema_for_llm (auto-mode) nie wolno wyemitować loginów
- BppUser przez jedyną eksponowaną relację: ``bpp.autor`` pole ``user``.
-
- djangoql-iplweb>=0.30.1 w auto-mode zaczął emitować konkretne wartości
- dla małych słowników (``related_values``/``match_field``) — bez
- jawnego ``fk_options`` wyciekłyby tu loginy (np. "admin")."""
- import json
-
- from djangoql.llm import describe_schema_for_llm
- from model_bakery import baker
-
- baker.make("bpp.BppUser", username="secret_login")
-
- d = describe_schema_for_llm(BppQLSchema(Rekord), format="json", max_fk_options=100)
-
- assert "bpp.autor" in d["models"]
- user_field = d["models"]["bpp.autor"]["user"]
- assert "related_values" not in user_field
- assert "match_field" not in user_field
- assert "related_examples" not in user_field
- assert "secret_login" not in json.dumps(d)
-
-
-@pytest.mark.django_db
-def test_graph_significantly_smaller():
- """Graf modeli mocno się kurczy po wykluczeniu aplikacji roboczych.
-
- Przed uszczelnieniem: 216 modeli (BFS z Rekord). Po: 86 (bpp + pbn_api +
- taggit — żadna z wykluczonych app_labels; `pbn_export_queue` NIE jest
- osiągalne w grafie). Próg < 100 zamiast przykładowego < 60 z briefu, bo
- `pbn_api` (celowo NIE wykluczone, niesie `pbn_uid`) oraz jego własne relacje
- (Publication, Institution, Scientist, Journal, …) dociągają część grafu z
- powrotem — patrz raport."""
- schema = BppQLSchema(Rekord)
- assert len(schema.models) < 100
diff --git a/uv.lock b/uv.lock
index 6b064e6af..fde23331b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -56,6 +56,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
]
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anthropic"
+version = "0.116.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "distro", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "docstring-parser", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "httpx", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "jiter", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pydantic", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "sniffio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/a2/d31f14e28d49bae983a3634e38dfb4b31c50110b5e403596c5c6a20b23f8/anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396", size = 949149, upload-time = "2026-07-02T19:08:10.534Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/dd/2a1e81cf1b163acc340afc4ec74ed1d86f5eed1a809fabdeed3e0997b346/anthropic-0.116.0-py3-none-any.whl", hash = "sha256:6c0a7698e8d652455da3499978279bb2588c7264d0a35be3666009a4258c8256", size = 956896, upload-time = "2026-07-02T19:08:08.756Z" },
+]
+
[[package]]
name = "anyio"
version = "4.11.0"
@@ -365,6 +393,7 @@ name = "bpp-iplweb"
version = "202607.1397"
source = { editable = "." }
dependencies = [
+ { name = "anthropic", marker = "platform_python_implementation != 'PyPy'" },
{ name = "arrow", marker = "platform_python_implementation != 'PyPy'" },
{ name = "babel", marker = "platform_python_implementation != 'PyPy'" },
{ name = "beautifulsoup4", marker = "platform_python_implementation != 'PyPy'" },
@@ -549,6 +578,7 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "anthropic", specifier = ">=0.40" },
{ name = "arrow", specifier = ">=1.3,<2" },
{ name = "babel", specifier = ">=2.17" },
{ name = "beautifulsoup4", specifier = ">=4.15.0" },
@@ -1437,6 +1467,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
[[package]]
name = "dj-pagination"
version = "2.5.0"
@@ -2481,6 +2520,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
]
+[[package]]
+name = "docstring-parser"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
+]
+
[[package]]
name = "docutils"
version = "0.21.2"
@@ -2848,6 +2896,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" },
]
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "h11", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
[[package]]
name = "httptools"
version = "0.8.0"
@@ -2891,6 +2952,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "certifi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "httpcore", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "idna", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
[[package]]
name = "hyperframe"
version = "6.1.0"
@@ -3114,6 +3190,92 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
+[[package]]
+name = "jiter"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" },
+ { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" },
+ { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" },
+ { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" },
+ { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" },
+ { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" },
+ { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" },
+ { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" },
+ { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" },
+ { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" },
+ { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" },
+ { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" },
+ { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" },
+ { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" },
+ { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" },
+ { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" },
+ { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" },
+ { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" },
+ { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" },
+ { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" },
+ { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" },
+ { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" },
+ { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" },
+ { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" },
+ { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" },
+ { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" },
+ { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" },
+ { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" },
+ { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" },
+ { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" },
+ { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" },
+]
+
[[package]]
name = "jmespath"
version = "1.1.0"
@@ -3794,7 +3956,7 @@ wheels = [
[[package]]
name = "openai"
-version = "2.44.0"
+version = "2.45.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "platform_python_implementation != 'PyPy'" },
@@ -3806,9 +3968,9 @@ dependencies = [
{ name = "tqdm", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" },
]
[[package]]
@@ -4378,6 +4540,123 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "pydantic-core", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-inspection", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
+ { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
+ { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
+ { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
+ { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+ { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+ { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+ { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+ { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
+ { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
+ { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+ { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
+ { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
+ { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
+]
+
[[package]]
name = "pydyf"
version = "0.11.0"
@@ -5945,6 +6224,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
[[package]]
name = "tzdata"
version = "2025.2"
From d3e1346a8cbd8f1d283c22f76e436b72dfd9393a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Tue, 14 Jul 2026 23:14:04 +0200
Subject: [PATCH 25/27] feat(ai-search): ekran instrukcji konfiguracji zamiast
404 (dla personelu)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Gdy AI nie jest skonfigurowane, zamiast Http404 personel z dostępem do
edytora zapytań widzi teraz ekran z instrukcją, jak włączyć funkcję.
Niezalogowani/bez uprawnień: bez zmian (dostęp odcięty przez mixin).
- ai_search/config.py (nowe): configuration_state()/is_configured() —
jedno źródło prawdy. „Skonfigurowane" = BPP_AI_SEARCH_ENABLED ORAZ
poświadczenia aktywnego backendu (klucz dla anthropic, BPP_AI_BASE_URL
dla openai). Zwraca konkretny powód braku (reason) do wyświetlenia.
- views.py: usunięto „raise Http404 gdy wyłączone"; get()/post() renderują
ai_search/nieskonfigurowane.html gdy nieskonfigurowane. Gate dostępu
(login + staff) pozostaje w mixinie — instrukcję widzi tylko personel.
- context_processors.py: dokłada BPP_AI_SEARCH_CONFIGURED.
- top_bar.html: pozycja „przez sztuczną inteligencję" widoczna dla
personelu ZAWSZE; gdy nieskonfigurowane — dopisek „(konfiguracja)"
prowadzący do instrukcji (wcześniej: chowana gdy wyłączone).
- backends.py: AnthropicBackend honoruje BPP_AI_API_KEY (jednolite
ustawienie klucza, fallback do env ANTHROPIC_API_KEY) — żeby wykrywanie
„skonfigurowane" było prawdziwe.
- testy: test_config.py (nowe), aktualizacja test_menu/test_views/
test_backends; newsfragment + docs.
Pełny suite bez playwright: 7708 passed, 0 failed.
---
docs/deweloper/ai-search.md | 6 +-
src/ai_search/backends.py | 8 +-
src/ai_search/config.py | 79 +++++++++++++++++++
src/ai_search/context_processors.py | 14 +++-
.../ai_search/nieskonfigurowane.html | 68 ++++++++++++++++
src/ai_search/tests/test_backends.py | 3 +-
src/ai_search/tests/test_config.py | 74 +++++++++++++++++
src/ai_search/tests/test_menu.py | 25 +++++-
src/ai_search/tests/test_views.py | 59 ++++++++++++++
src/ai_search/views.py | 31 ++++++--
...search-instrukcja-konfiguracji.feature.rst | 6 ++
src/django_bpp/templates/top_bar.html | 7 +-
12 files changed, 363 insertions(+), 17 deletions(-)
create mode 100644 src/ai_search/config.py
create mode 100644 src/ai_search/templates/ai_search/nieskonfigurowane.html
create mode 100644 src/ai_search/tests/test_config.py
create mode 100644 src/bpp/newsfragments/ai-search-instrukcja-konfiguracji.feature.rst
diff --git a/docs/deweloper/ai-search.md b/docs/deweloper/ai-search.md
index 30d6b81ba..931de7b14 100644
--- a/docs/deweloper/ai-search.md
+++ b/docs/deweloper/ai-search.md
@@ -11,11 +11,11 @@ loguje każde zapytanie wraz z kosztem w `ai_search.models.AISearchQuery`
| Zmienna | Domyślna | Znaczenie |
|---|---|---|
-| `BPP_AI_SEARCH_ENABLED` | `False` | Włącza feature (link w menu + widok; bez tego widok zwraca 404). |
-| `ANTHROPIC_API_KEY` | — | Klucz API Anthropic (wymagany do realnych wywołań backendu `anthropic`; SDK `anthropic` czyta go bezpośrednio ze środowiska). |
+| `BPP_AI_SEARCH_ENABLED` | `False` | Włącza feature (link w menu + działający formularz). Gdy wyłączone **lub** brak poświadczeń aktywnego backendu, personel z dostępem do edytora zapytań widzi ekran instrukcji konfiguracji (zamiast 404), a pozycja w menu ma dopisek „(konfiguracja)". Niezalogowani/bez uprawnień: bez zmian (niewidoczne). |
+| `ANTHROPIC_API_KEY` | — | Klucz API Anthropic. Alternatywa dla `BPP_AI_API_KEY` (to drugie ma pierwszeństwo); SDK `anthropic` czyta `ANTHROPIC_API_KEY` ze środowiska jako fallback. |
| `BPP_AI_BACKEND` | `anthropic` | `anthropic` (natywny SDK, płatny, budżet PLN) albo `openai` (lokalny/self-hosted serwer OpenAI-compatible — darmowy, budżet nieaktywny). Patrz [„Modele lokalne"](#modele-lokalne) niżej. |
| `BPP_AI_BASE_URL` | `""` | Tylko dla `BPP_AI_BACKEND=openai` — adres API zgodny z OpenAI (np. `http://localhost:11434/v1` dla Ollama). |
-| `BPP_AI_API_KEY` | `""` | Tylko dla `BPP_AI_BACKEND=openai` — klucz API (pusty dla serwerów bez auth, np. Ollama). |
+| `BPP_AI_API_KEY` | `""` | Klucz API. Dla `openai` — klucz lokalnego serwera (pusty dla serwerów bez auth, np. Ollama). Dla `anthropic` — jednolita alternatywa dla `ANTHROPIC_API_KEY` (jeśli ustawione, ma pierwszeństwo). |
| `BPP_AI_MODEL` | `claude-sonnet-5` | Model używany do tłumaczenia NL->DSL. Dla `anthropic` musi mieć wpis w `BPP_AI_PRICING` (cennik) w `settings/base.py`; dla `openai` to nazwa modelu na lokalnym serwerze (np. `qwen3:8b`). |
| `BPP_AI_DAILY_BUDGET_PLN` / `BPP_AI_MONTHLY_BUDGET_PLN` | `20` / `300` | Twarde limity kosztu (PLN); po przekroczeniu `ai_search.budget.check_budget()` blokuje kolejne zapytania (widok zwraca 200 z komunikatem, nic nie loguje). |
| `BPP_AI_MAX_RETRIES` | `1` | Ile razy `translator.translate` ponawia zapytanie do modelu po błędzie składni DjangoQL (z konkretnym komunikatem błędu, linia/kolumna). |
diff --git a/src/ai_search/backends.py b/src/ai_search/backends.py
index 8d485a294..6302087d4 100644
--- a/src/ai_search/backends.py
+++ b/src/ai_search/backends.py
@@ -50,7 +50,13 @@ class AnthropicBackend:
"""Natywny SDK ``anthropic`` — structured output, prompt caching."""
def _client(self) -> anthropic.Anthropic:
- return anthropic.Anthropic(timeout=settings.BPP_AI_LLM_TIMEOUT)
+ # ``BPP_AI_API_KEY`` jako jednolite ustawienie klucza (spójne z
+ # OpenAICompatibleBackend i z ai_search.config); puste -> None ->
+ # SDK sięga do zmiennej środowiskowej ANTHROPIC_API_KEY.
+ return anthropic.Anthropic(
+ api_key=settings.BPP_AI_API_KEY or None,
+ timeout=settings.BPP_AI_LLM_TIMEOUT,
+ )
def _extract_usage(self, resp) -> dict:
u = resp.usage
diff --git a/src/ai_search/config.py b/src/ai_search/config.py
new file mode 100644
index 000000000..534187afd
--- /dev/null
+++ b/src/ai_search/config.py
@@ -0,0 +1,79 @@
+"""Wykrywanie stanu konfiguracji wyszukiwania AI.
+
+Jedno źródło prawdy o tym, czy funkcja „szukaj przez AI" jest GOTOWA do
+użycia (``BPP_AI_SEARCH_ENABLED`` + poświadczenia aktywnego backendu), czy
+tylko dostępna jako ekran instrukcji konfiguracji. Używane przez:
+
+- widok ``ZapytanieAIView`` — formularz (skonfigurowane) vs instrukcja
+ (nieskonfigurowane),
+- context processor ``ai_search_flags`` — pozycja menu + dopisek
+ „(konfiguracja)".
+
+Bez efektów ubocznych i tanie (czyta tylko ``settings`` + ``os.environ``),
+więc bezpieczne do wołania per-request w context processorze.
+"""
+
+import os
+from dataclasses import dataclass
+
+from django.conf import settings
+
+from ai_search.backends import active_backend_name
+
+
+@dataclass(frozen=True)
+class ConfigState:
+ """Stan konfiguracji AI. ``configured`` == gotowe do użycia."""
+
+ configured: bool
+ enabled: bool
+ backend: str
+ reason: str = ""
+
+
+def _anthropic_api_key() -> str:
+ """Klucz API Anthropic. Pierwszeństwo ma ustawienie ``BPP_AI_API_KEY``,
+ z fallbackiem do zmiennej środowiskowej ``ANTHROPIC_API_KEY`` (domyślne
+ źródło klucza w SDK ``anthropic``). Pusty string == brak klucza."""
+ return settings.BPP_AI_API_KEY or os.environ.get("ANTHROPIC_API_KEY", "")
+
+
+def configuration_state() -> ConfigState:
+ """Oblicz aktualny stan konfiguracji wyszukiwania AI."""
+ backend = active_backend_name()
+ enabled = bool(settings.BPP_AI_SEARCH_ENABLED)
+
+ if not enabled:
+ return ConfigState(
+ configured=False,
+ enabled=False,
+ backend=backend,
+ reason="Wyszukiwanie AI jest wyłączone "
+ "(ustaw zmienną BPP_AI_SEARCH_ENABLED=1).",
+ )
+
+ if backend == "openai":
+ if not settings.BPP_AI_BASE_URL:
+ return ConfigState(
+ configured=False,
+ enabled=True,
+ backend=backend,
+ reason="Backend openai (model lokalny) wymaga adresu "
+ "serwera — ustaw BPP_AI_BASE_URL "
+ "(np. http://localhost:11434/v1).",
+ )
+ elif not _anthropic_api_key():
+ return ConfigState(
+ configured=False,
+ enabled=True,
+ backend=backend,
+ reason="Backend anthropic wymaga klucza API — ustaw "
+ "BPP_AI_API_KEY lub zmienną środowiskową ANTHROPIC_API_KEY.",
+ )
+
+ return ConfigState(configured=True, enabled=True, backend=backend)
+
+
+def is_configured() -> bool:
+ """Czy funkcja AI jest w pełni skonfigurowana (gotowa do użycia)."""
+ return configuration_state().configured
diff --git a/src/ai_search/context_processors.py b/src/ai_search/context_processors.py
index f59dc3d50..81a3e4f02 100644
--- a/src/ai_search/context_processors.py
+++ b/src/ai_search/context_processors.py
@@ -1,5 +1,17 @@
from django.conf import settings
+from ai_search.config import is_configured
+
def ai_search_flags(request):
- return {"BPP_AI_SEARCH_ENABLED": settings.BPP_AI_SEARCH_ENABLED}
+ """Flagi AI dla szablonów (menu górne).
+
+ - ``BPP_AI_SEARCH_ENABLED`` — surowa flaga funkcji (zachowana dla zgodności).
+ - ``BPP_AI_SEARCH_CONFIGURED`` — czy funkcja jest GOTOWA do użycia (flaga +
+ poświadczenia backendu). Gdy ``False``, pozycja menu prowadzi do ekranu
+ instrukcji konfiguracji zamiast do formularza.
+ """
+ return {
+ "BPP_AI_SEARCH_ENABLED": settings.BPP_AI_SEARCH_ENABLED,
+ "BPP_AI_SEARCH_CONFIGURED": is_configured(),
+ }
diff --git a/src/ai_search/templates/ai_search/nieskonfigurowane.html b/src/ai_search/templates/ai_search/nieskonfigurowane.html
new file mode 100644
index 000000000..986e4ac34
--- /dev/null
+++ b/src/ai_search/templates/ai_search/nieskonfigurowane.html
@@ -0,0 +1,68 @@
+{% extends "base.html" %}
+{% block extratitle %}Szukaj przez AI — konfiguracja{% endblock %}
+{% block content %}
+
+
+
+
+ Szukaj przez sztuczną inteligencję
+
+
+
+
Ta funkcja nie jest jeszcze skonfigurowana.
+
{{ state.reason }}
+
+
+
Jak włączyć wyszukiwanie AI
+
+ Ustaw poniższe zmienne środowiskowe serwera BPP i zrestartuj aplikację.
+ Konfiguracji dokonuje administrator instalacji (nie z poziomu tej strony).
+
+
+
+ -
+
Włącz funkcję:
+ BPP_AI_SEARCH_ENABLED=1
+
+ -
+
Wybierz silnik (backend) modelu:
+
+ -
+
+ Anthropic (Claude) — płatny, w chmurze
+ (domyślny):
+
+ BPP_AI_BACKEND=anthropic
+BPP_AI_API_KEY=sk-ant-... # albo zmienna ANTHROPIC_API_KEY
+BPP_AI_MODEL=claude-sonnet-5 # opcjonalnie
+
+ -
+
+ Model lokalny (Ollama, llama.cpp, vLLM,
+ LM Studio…) — dowolny serwer zgodny z OpenAI Chat
+ Completions API, darmowy:
+
+ BPP_AI_BACKEND=openai
+BPP_AI_BASE_URL=http://localhost:11434/v1
+BPP_AI_MODEL=... # nazwa modelu lokalnego
+BPP_AI_API_KEY=... # opcjonalnie, jeśli serwer wymaga
+
+
+
+ -
+
+ (Anthropic) Ustaw dzienny i miesięczny limit kosztów w PLN — po ich
+ przekroczeniu zapytania są twardo blokowane:
+
+ BPP_AI_DAILY_BUDGET_PLN=20
+BPP_AI_MONTHLY_BUDGET_PLN=300
+
+
+
+
+ Pełna dokumentacja opcji (modele, cennik, kurs walut, cache schematu):
+ docs/deweloper/ai-search.md.
+
+
+
+{% endblock %}
diff --git a/src/ai_search/tests/test_backends.py b/src/ai_search/tests/test_backends.py
index c215ff717..ec59e1f4d 100644
--- a/src/ai_search/tests/test_backends.py
+++ b/src/ai_search/tests/test_backends.py
@@ -44,6 +44,7 @@ def _system_blocks():
def test_anthropic_backend_call_maps_usage_and_stop_reason(settings):
settings.BPP_AI_MODEL = "claude-sonnet-5"
settings.BPP_AI_LLM_TIMEOUT = 30
+ settings.BPP_AI_API_KEY = "sk-ant-xyz"
parsed = backends.DSLQuery(query="rok = 2024", error=None)
fake_resp = mock.Mock()
fake_resp.parsed_output = parsed
@@ -60,7 +61,7 @@ def test_anthropic_backend_call_maps_usage_and_stop_reason(settings):
result = backends.AnthropicBackend().call(
_system_blocks(), [{"role": "user", "content": "pytanie"}]
)
- ctor.assert_called_once_with(timeout=30)
+ ctor.assert_called_once_with(api_key="sk-ant-xyz", timeout=30)
assert result.parsed is parsed
assert result.stop_reason == "end_turn"
assert result.usage == {
diff --git a/src/ai_search/tests/test_config.py b/src/ai_search/tests/test_config.py
new file mode 100644
index 000000000..bf8a55126
--- /dev/null
+++ b/src/ai_search/tests/test_config.py
@@ -0,0 +1,74 @@
+"""Testy wykrywania stanu konfiguracji AI (``ai_search.config``)."""
+
+import pytest
+
+from ai_search import config
+
+
+def test_disabled_is_not_configured(settings):
+ settings.BPP_AI_SEARCH_ENABLED = False
+ state = config.configuration_state()
+ assert state.configured is False
+ assert state.enabled is False
+ assert "BPP_AI_SEARCH_ENABLED" in state.reason
+ assert config.is_configured() is False
+
+
+def test_anthropic_without_key_not_configured(settings, monkeypatch):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "anthropic"
+ settings.BPP_AI_API_KEY = ""
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ state = config.configuration_state()
+ assert state.configured is False
+ assert state.enabled is True
+ assert "anthropic" in state.reason.lower()
+
+
+def test_anthropic_with_settings_key_configured(settings, monkeypatch):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "anthropic"
+ settings.BPP_AI_API_KEY = "sk-ant-test"
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ assert config.is_configured() is True
+
+
+def test_anthropic_with_env_key_configured(settings, monkeypatch):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "anthropic"
+ settings.BPP_AI_API_KEY = ""
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-from-env")
+ assert config.is_configured() is True
+
+
+def test_miscased_backend_treated_as_anthropic_needs_key(settings, monkeypatch):
+ # 'Anthropic' (zła wielkość liter) -> traktowane jak anthropic -> wymaga
+ # klucza (spójne z active_backend_name / get_backend).
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "Anthropic"
+ settings.BPP_AI_API_KEY = ""
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ assert config.is_configured() is False
+
+
+def test_openai_without_base_url_not_configured(settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "openai"
+ settings.BPP_AI_BASE_URL = ""
+ state = config.configuration_state()
+ assert state.configured is False
+ assert "BPP_AI_BASE_URL" in state.reason
+
+
+def test_openai_with_base_url_configured(settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "openai"
+ settings.BPP_AI_BASE_URL = "http://localhost:11434/v1"
+ assert config.is_configured() is True
+
+
+@pytest.mark.parametrize("enabled", [True, False])
+def test_state_reports_active_backend(settings, enabled):
+ settings.BPP_AI_SEARCH_ENABLED = enabled
+ settings.BPP_AI_BACKEND = "openai"
+ assert config.configuration_state().backend == "openai"
diff --git a/src/ai_search/tests/test_menu.py b/src/ai_search/tests/test_menu.py
index 9ea16ee5f..a73ae1d78 100644
--- a/src/ai_search/tests/test_menu.py
+++ b/src/ai_search/tests/test_menu.py
@@ -12,14 +12,33 @@ def staff_client(client, django_user_model):
@pytest.mark.django_db
-def test_menu_item_shown_when_enabled(staff_client, settings):
+def test_menu_item_shown_when_configured(staff_client, settings):
settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "anthropic"
+ settings.BPP_AI_API_KEY = "sk-ant-test"
r = staff_client.get("/")
- assert reverse("ai_search:index") in r.content.decode()
+ body = r.content.decode()
+ assert reverse("ai_search:index") in body
+ # pełny tryb — bez dopisku konfiguracyjnego
+ assert "(konfiguracja)" not in body
@pytest.mark.django_db
-def test_menu_item_hidden_when_disabled(staff_client, settings):
+def test_menu_item_shown_with_hint_when_not_configured(staff_client, settings):
+ # Zmiana zachowania: gdy AI nie jest skonfigurowane, pozycja NADAL jest
+ # widoczna dla personelu (prowadzi do instrukcji), ale z dopiskiem.
settings.BPP_AI_SEARCH_ENABLED = False
r = staff_client.get("/")
+ body = r.content.decode()
+ assert reverse("ai_search:index") in body
+ assert "(konfiguracja)" in body
+
+
+@pytest.mark.django_db
+def test_menu_item_hidden_for_non_editor(client, django_user_model, settings):
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_API_KEY = "sk-ant-test"
+ u = django_user_model.objects.create_user(username="zwykly", password="x")
+ client.force_login(u)
+ r = client.get("/")
assert reverse("ai_search:index") not in r.content.decode()
diff --git a/src/ai_search/tests/test_views.py b/src/ai_search/tests/test_views.py
index 53a99a5c2..9a635066f 100644
--- a/src/ai_search/tests/test_views.py
+++ b/src/ai_search/tests/test_views.py
@@ -17,6 +17,16 @@ def staff_client(client, django_user_model):
return client
+@pytest.fixture(autouse=True)
+def _ai_configured(settings):
+ """Domyślnie środowisko SKONFIGUROWANE: klucz anthropic + adres backendu
+ lokalnego. Testy poniżej ćwiczą ścieżkę działającego formularza (wymaga
+ ``config.is_configured() == True``). Testy „nieskonfigurowane" jawnie
+ nadpisują flagę/klucz."""
+ settings.BPP_AI_API_KEY = "sk-ant-test"
+ settings.BPP_AI_BASE_URL = "http://localhost:11434/v1"
+
+
@pytest.mark.django_db
def test_anonymous_denied(client, settings):
settings.BPP_AI_SEARCH_ENABLED = True
@@ -29,6 +39,55 @@ def test_get_form_visible_for_staff(staff_client, settings):
settings.BPP_AI_SEARCH_ENABLED = True
r = staff_client.get(reverse("ai_search:index"))
assert r.status_code == 200
+ # skonfigurowane -> widać formularz, nie ekran instrukcji
+ assert "data-ai-search-form" in r.content.decode()
+
+
+@pytest.mark.django_db
+def test_not_configured_shows_instructions_for_staff(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = False
+ r = staff_client.get(reverse("ai_search:index"))
+ assert r.status_code == 200
+ body = r.content.decode()
+ assert "nie jest jeszcze skonfigurowana" in body
+ assert "BPP_AI_SEARCH_ENABLED" in body
+ # to instrukcja, nie formularz
+ assert "data-ai-search-form" not in body
+
+
+@pytest.mark.django_db
+def test_not_configured_missing_key_shows_instructions(
+ staff_client, settings, monkeypatch
+):
+ # Flaga włączona, ale brak klucza -> nadal „nieskonfigurowane".
+ settings.BPP_AI_SEARCH_ENABLED = True
+ settings.BPP_AI_BACKEND = "anthropic"
+ settings.BPP_AI_API_KEY = ""
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ r = staff_client.get(reverse("ai_search:index"))
+ assert r.status_code == 200
+ assert "nie jest jeszcze skonfigurowana" in r.content.decode()
+
+
+@pytest.mark.django_db
+def test_not_configured_denies_anonymous(client, settings):
+ # Niezalogowany NIE widzi instrukcji — dostęp odcięty przez mixin.
+ settings.BPP_AI_SEARCH_ENABLED = False
+ r = client.get(reverse("ai_search:index"))
+ assert r.status_code in (302, 403)
+ assert "nie jest jeszcze skonfigurowana" not in r.content.decode()
+
+
+@pytest.mark.django_db
+def test_not_configured_post_does_not_process_form(staff_client, settings):
+ settings.BPP_AI_SEARCH_ENABLED = False
+ r = staff_client.post(
+ reverse("ai_search:index"),
+ {"model": "rekord", "pytanie": "cokolwiek"},
+ )
+ assert r.status_code == 200
+ assert "nie jest jeszcze skonfigurowana" in r.content.decode()
+ assert AISearchQuery.objects.count() == 0
@pytest.mark.django_db
diff --git a/src/ai_search/views.py b/src/ai_search/views.py
index 882c61613..5ebaea027 100644
--- a/src/ai_search/views.py
+++ b/src/ai_search/views.py
@@ -4,12 +4,13 @@
import rollbar
from django.conf import settings
-from django.http import Http404, HttpResponseRedirect
+from django.http import HttpResponseRedirect
+from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.views.generic import FormView
-from ai_search import backends, budget, fx, pricing, translator
+from ai_search import backends, budget, config, fx, pricing, translator
from ai_search.forms import AISearchForm
from ai_search.models import AISearchQuery
from bpp.views.zapytanie import WprowadzanieDanychOrSuperuserMixin
@@ -24,10 +25,28 @@ class ZapytanieAIView(WprowadzanieDanychOrSuperuserMixin, FormView):
template_name = "ai_search/zapytanie_ai.html"
form_class = AISearchForm
- def dispatch(self, request, *args, **kwargs):
- if not settings.BPP_AI_SEARCH_ENABLED:
- raise Http404("Wyszukiwanie AI jest wyłączone.")
- return super().dispatch(request, *args, **kwargs)
+ def get(self, request, *args, **kwargs):
+ if not config.is_configured():
+ return self._render_instrukcje()
+ return super().get(request, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ if not config.is_configured():
+ return self._render_instrukcje()
+ return super().post(request, *args, **kwargs)
+
+ def _render_instrukcje(self):
+ """Ekran instrukcji konfiguracji zamiast formularza, gdy AI nie jest
+ skonfigurowane. Dostęp (login + staff/superuser) jest już wymuszony
+ przez ``WprowadzanieDanychOrSuperuserMixin`` w ``dispatch()``, więc
+ użytkownicy niezalogowani i bez uprawnień tu nie trafiają (dostają
+ odpowiednio redirect na logowanie / 403) — instrukcję widzi tylko
+ personel mogący korzystać z edytora zapytań."""
+ return render(
+ self.request,
+ "ai_search/nieskonfigurowane.html",
+ {"state": config.configuration_state()},
+ )
def form_valid(self, form):
model_key = form.cleaned_data["model"]
diff --git a/src/bpp/newsfragments/ai-search-instrukcja-konfiguracji.feature.rst b/src/bpp/newsfragments/ai-search-instrukcja-konfiguracji.feature.rst
new file mode 100644
index 000000000..399ebcba2
--- /dev/null
+++ b/src/bpp/newsfragments/ai-search-instrukcja-konfiguracji.feature.rst
@@ -0,0 +1,6 @@
+Gdy wyszukiwanie „przez sztuczną inteligencję" nie jest jeszcze
+skonfigurowane (wyłączona flaga ``BPP_AI_SEARCH_ENABLED`` lub brak klucza
+API / adresu backendu), personel z dostępem do edytora zapytań widzi teraz
+ekran z instrukcją konfiguracji zamiast błędu 404, a pozycja w menu jest
+widoczna z dopiskiem „(konfiguracja)". Dla użytkowników niezalogowanych i bez
+uprawnień nic się nie zmienia — funkcja pozostaje dla nich niewidoczna.
diff --git a/src/django_bpp/templates/top_bar.html b/src/django_bpp/templates/top_bar.html
index fa6a0d145..269b588c2 100644
--- a/src/django_bpp/templates/top_bar.html
+++ b/src/django_bpp/templates/top_bar.html
@@ -40,11 +40,14 @@
{% endif %}
{% load query_editor %}
- {% if request.user|can_use_query_editor and BPP_AI_SEARCH_ENABLED %}
+ {% if request.user|can_use_query_editor %}
+ {# Widoczne dla personelu zawsze; gdy AI nie jest #}
+ {# skonfigurowane, prowadzi do ekranu instrukcji #}
+ {# (dopisek „konfiguracja”). #}
- przez sztuczną inteligencję
+ przez sztuczną inteligencję{% if not BPP_AI_SEARCH_CONFIGURED %} (konfiguracja){% endif %}
{% endif %}
From 4f240039086bd02918d62855845280156dd5e65a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Tue, 14 Jul 2026 23:26:14 +0200
Subject: [PATCH 26/27] =?UTF-8?q?test(ai-search):=20kontrakt=20generuj?=
=?UTF-8?q?=E2=86=92wykonaj=20(RekordLLMSchema=20=E2=8A=86=20BppQLSchemaOg?=
=?UTF-8?q?raniczony)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ai_search opisuje LLM-owi schemat przez RekordLLMSchema (ten sam, którego
/api/v1/zapytanie/ używa do WYKONANIA zapytań agenta), a wygenerowane
zapytanie przekierowuje do /zapytanie/, gdzie waliduje je BppQLSchemaOgraniczony.
Test pilnuje, że powierzchnia pól/modeli RekordLLMSchema jest podzbiorem
BppQLSchemaOgraniczony — więc AI nie wygeneruje zapytania odrzucanego przy
wykonaniu. Zweryfikowane dla obu modeli ai_search (rekord, autor).
---
src/ai_search/tests/test_schema_compat.py | 61 +++++++++++++++++++++++
1 file changed, 61 insertions(+)
create mode 100644 src/ai_search/tests/test_schema_compat.py
diff --git a/src/ai_search/tests/test_schema_compat.py b/src/ai_search/tests/test_schema_compat.py
new file mode 100644
index 000000000..ef40606f3
--- /dev/null
+++ b/src/ai_search/tests/test_schema_compat.py
@@ -0,0 +1,61 @@
+"""Kontrakt „generuj → wykonaj" dla wyszukiwania AI.
+
+``ai_search`` opisuje LLM-owi schemat przez ``RekordLLMSchema`` (kanoniczny
+schemat agent-facing z gałęzi dev — ten sam, którego używa
+``/api/v1/zapytanie/``), a następnie przekierowuje wygenerowane zapytanie do
+edytora ``/zapytanie/`` (``bpp:zapytanie``), który WALIDUJE i wykonuje je pod
+``BppQLSchemaOgraniczony``.
+
+Żeby wygenerowane zapytanie nie było odrzucone przy wykonaniu, powierzchnia
+pól ``RekordLLMSchema`` MUSI być podzbiorem ``BppQLSchemaOgraniczony``
+(``BppQLSchemaOgraniczony`` = ``RekordLLMSchema`` + pickery ``__rel`` +
+ewentualnie pola, których ``RekordLLMSchema`` świadomie nie pokazuje LLM-owi).
+Te testy pilnują tego niezmiennika dla obu modeli, po których pyta ai_search
+(``rekord`` i ``autor``).
+"""
+
+import pytest
+
+from bpp.djangoql_schema import BppQLSchemaOgraniczony, RekordLLMSchema
+from bpp.models import Autor
+from bpp.models.cache import Rekord
+
+
+def _surface(schema_cls, model):
+ """Mapa ``{etykieta_modelu: set(pól)}`` dla danego schematu i modelu
+ startowego."""
+ return {label: set(fields) for label, fields in schema_cls(model).models.items()}
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("model", [Rekord, Autor])
+def test_llm_schema_models_subset_of_web_editor(model):
+ """Każdy model osiągalny w ``RekordLLMSchema`` jest też osiągalny w
+ ``BppQLSchemaOgraniczony`` (ten sam allow-list ``SEARCH_ALLOWLIST``)."""
+ llm = _surface(RekordLLMSchema, model)
+ web = _surface(BppQLSchemaOgraniczony, model)
+ brakujace = set(llm) - set(web)
+ assert not brakujace, (
+ f"RekordLLMSchema({model.__name__}) osiąga modele nieobecne w "
+ f"BppQLSchemaOgraniczony: {sorted(brakujace)} — LLM wygenerowałby "
+ f"zapytanie odrzucane przez /zapytanie/."
+ )
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("model", [Rekord, Autor])
+def test_llm_schema_fields_subset_of_web_editor(model):
+ """Każde pole wyrażalne w ``RekordLLMSchema`` jest też wyrażalne w
+ ``BppQLSchemaOgraniczony`` — inaczej wygenerowane przez AI zapytanie
+ zostałoby odrzucone przy wykonaniu w edytorze ``/zapytanie/``."""
+ llm = _surface(RekordLLMSchema, model)
+ web = _surface(BppQLSchemaOgraniczony, model)
+ naruszenia = {
+ label: sorted(fields - web.get(label, set()))
+ for label, fields in llm.items()
+ if fields - web.get(label, set())
+ }
+ assert not naruszenia, (
+ f"RekordLLMSchema({model.__name__}) wyraża pola spoza "
+ f"BppQLSchemaOgraniczony (odrzucane przez /zapytanie/): {naruszenia}"
+ )
From cb68a2548adb0a161276c17dade4da03ae64a4c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Tue, 14 Jul 2026 23:33:52 +0200
Subject: [PATCH 27/27] fix(ai-search): placeholdery kluczy bez prefiksu
sk-ant- (false-positive GitGuardian)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Atrapy kluczy w testach i przykład w szablonie instrukcji używały prefiksu
`sk-ant-` (format klucza Anthropic) — GitGuardian raportował je jako
„uncovered secret" (false-positive; to nie są realne klucze). Zamiana na
`test-anthropic-key` / `` — bez zmiany logiki
(is_configured sprawdza tylko niepustość). Kontynuacja b5510b90f.
---
src/ai_search/templates/ai_search/nieskonfigurowane.html | 2 +-
src/ai_search/tests/test_backends.py | 4 ++--
src/ai_search/tests/test_config.py | 4 ++--
src/ai_search/tests/test_menu.py | 4 ++--
src/ai_search/tests/test_views.py | 2 +-
5 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/src/ai_search/templates/ai_search/nieskonfigurowane.html b/src/ai_search/templates/ai_search/nieskonfigurowane.html
index 986e4ac34..07c8f9519 100644
--- a/src/ai_search/templates/ai_search/nieskonfigurowane.html
+++ b/src/ai_search/templates/ai_search/nieskonfigurowane.html
@@ -33,7 +33,7 @@ Jak włączyć wyszukiwanie AI
(domyślny):
BPP_AI_BACKEND=anthropic
-BPP_AI_API_KEY=sk-ant-... # albo zmienna ANTHROPIC_API_KEY
+BPP_AI_API_KEY=<klucz-API-Anthropic> # albo zmienna ANTHROPIC_API_KEY
BPP_AI_MODEL=claude-sonnet-5 # opcjonalnie
diff --git a/src/ai_search/tests/test_backends.py b/src/ai_search/tests/test_backends.py
index ec59e1f4d..4fb2c5d6d 100644
--- a/src/ai_search/tests/test_backends.py
+++ b/src/ai_search/tests/test_backends.py
@@ -44,7 +44,7 @@ def _system_blocks():
def test_anthropic_backend_call_maps_usage_and_stop_reason(settings):
settings.BPP_AI_MODEL = "claude-sonnet-5"
settings.BPP_AI_LLM_TIMEOUT = 30
- settings.BPP_AI_API_KEY = "sk-ant-xyz"
+ settings.BPP_AI_API_KEY = "test-anthropic-key"
parsed = backends.DSLQuery(query="rok = 2024", error=None)
fake_resp = mock.Mock()
fake_resp.parsed_output = parsed
@@ -61,7 +61,7 @@ def test_anthropic_backend_call_maps_usage_and_stop_reason(settings):
result = backends.AnthropicBackend().call(
_system_blocks(), [{"role": "user", "content": "pytanie"}]
)
- ctor.assert_called_once_with(api_key="sk-ant-xyz", timeout=30)
+ ctor.assert_called_once_with(api_key="test-anthropic-key", timeout=30)
assert result.parsed is parsed
assert result.stop_reason == "end_turn"
assert result.usage == {
diff --git a/src/ai_search/tests/test_config.py b/src/ai_search/tests/test_config.py
index bf8a55126..0c4fb3754 100644
--- a/src/ai_search/tests/test_config.py
+++ b/src/ai_search/tests/test_config.py
@@ -28,7 +28,7 @@ def test_anthropic_without_key_not_configured(settings, monkeypatch):
def test_anthropic_with_settings_key_configured(settings, monkeypatch):
settings.BPP_AI_SEARCH_ENABLED = True
settings.BPP_AI_BACKEND = "anthropic"
- settings.BPP_AI_API_KEY = "sk-ant-test"
+ settings.BPP_AI_API_KEY = "test-anthropic-key"
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
assert config.is_configured() is True
@@ -37,7 +37,7 @@ def test_anthropic_with_env_key_configured(settings, monkeypatch):
settings.BPP_AI_SEARCH_ENABLED = True
settings.BPP_AI_BACKEND = "anthropic"
settings.BPP_AI_API_KEY = ""
- monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-from-env")
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-from-env")
assert config.is_configured() is True
diff --git a/src/ai_search/tests/test_menu.py b/src/ai_search/tests/test_menu.py
index a73ae1d78..e676c076e 100644
--- a/src/ai_search/tests/test_menu.py
+++ b/src/ai_search/tests/test_menu.py
@@ -15,7 +15,7 @@ def staff_client(client, django_user_model):
def test_menu_item_shown_when_configured(staff_client, settings):
settings.BPP_AI_SEARCH_ENABLED = True
settings.BPP_AI_BACKEND = "anthropic"
- settings.BPP_AI_API_KEY = "sk-ant-test"
+ settings.BPP_AI_API_KEY = "test-anthropic-key"
r = staff_client.get("/")
body = r.content.decode()
assert reverse("ai_search:index") in body
@@ -37,7 +37,7 @@ def test_menu_item_shown_with_hint_when_not_configured(staff_client, settings):
@pytest.mark.django_db
def test_menu_item_hidden_for_non_editor(client, django_user_model, settings):
settings.BPP_AI_SEARCH_ENABLED = True
- settings.BPP_AI_API_KEY = "sk-ant-test"
+ settings.BPP_AI_API_KEY = "test-anthropic-key"
u = django_user_model.objects.create_user(username="zwykly", password="x")
client.force_login(u)
r = client.get("/")
diff --git a/src/ai_search/tests/test_views.py b/src/ai_search/tests/test_views.py
index 9a635066f..03f2ad1e1 100644
--- a/src/ai_search/tests/test_views.py
+++ b/src/ai_search/tests/test_views.py
@@ -23,7 +23,7 @@ def _ai_configured(settings):
lokalnego. Testy poniżej ćwiczą ścieżkę działającego formularza (wymaga
``config.is_configured() == True``). Testy „nieskonfigurowane" jawnie
nadpisują flagę/klucz."""
- settings.BPP_AI_API_KEY = "sk-ant-test"
+ settings.BPP_AI_API_KEY = "test-anthropic-key"
settings.BPP_AI_BASE_URL = "http://localhost:11434/v1"