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 %} + + {% endif %} + {% if wygenerowany_query %} +
+ Wygenerowane zapytanie: {{ wygenerowany_query }} +
+ {% endif %} + +
+ {% csrf_token %} + {{ form.as_p }} + +
+
+
+ +{% 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). +

    + +
      +
    1. +

      Włącz funkcję:

      +
      BPP_AI_SEARCH_ENABLED=1
      +
    2. +
    3. +

      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
        +
      • +
      +
    4. +
    5. +

      + (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
      +
    6. +
    + +

    + 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"