feat(ui): interaktywny wybor modelu u dostawcy
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m14s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 10m19s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 46s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 27s
build / build (push) Successful in 1m18s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m45s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m2s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 23s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m14s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 10m19s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 46s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 27s
build / build (push) Successful in 1m18s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m45s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m2s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 23s
Uzytkownik wybiera nie tylko dostawce, ale konkretny model — Fable czy Opus u Anthropica, gpt-4o-mini czy gpt-5 u OpenAI, cokolwiek ma pobrane lokalnie. - app/llm/catalog.py: podpowiedzi modeli per dostawca wraz z oknem kontekstu, nadpisywalne przez <DOSTAWCA>_MODELS; GET /llm/models wystawia je dla UI. - Pole modelu w UI jest TEKSTOWE z datalista, nie zamknietym <select> — konto moze miec dostep do modeli, o ktorych kod nie wie, a nowe wychodza szybciej, niz aktualizuje sie katalog. Puste pole = model domyslny dostawcy. - static/models.js: zmiana dostawcy przelacza podpowiedzi, podmienia placeholder na model domyslny i pokazuje okno kontekstu wybranego modelu. - build_provider(name, model) — model z zadania wygrywa nad konfiguracja. WAZNE (znalezione przy tescie e2e): liczenie budzetu „maksymalny kontekst modelu" szlo przez build_provider(), ktory WYMAGA klucza API — bez klucza budzet cicho spadal do wartosci zapasowej i byl identyczny dla wszystkich modeli Anthropic. Budzet zalezy wylacznie od okna kontekstu, wiec doszlo resolve_model(), ktore rozwiazuje nazwe modelu bez budowania dostawcy. Teraz budzet realnie sie rozni: Opus/Fable 3,48 mln znakow, Haiku 536 tys., gpt-4o-mini 438 tys., llama3.1 8 tys. Pewnosc danych w katalogu: modele Anthropic pochodza z oficjalnej dokumentacji API (okna i limity zgodne z limits.py); modele OpenAI to podpowiedzi, ktorych nie weryfikowalem; lokalne zaleza od tego, co masz pobrane. Testy: 174 passed / 1 skipped (logika) + 17 (prezentacja). Nowy test strukturalny pilnuje, ze KAZDE wywolanie w dol niesie wybrany model i dostawce — dokladnie ta klasa bledu zlapala brakujacy parametr przy horoskopie okresowym. Zweryfikowane w przegladarce: przelaczanie dostawcy podmienia liste modeli. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #18.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""Katalog modeli do wyboru w UI (LOG-31).
|
||||
|
||||
To są **podpowiedzi**, nie zamknięta lista. Pole modelu w UI jest tekstowe z
|
||||
datalistą, więc można wpisać dowolny identyfikator — konto może mieć dostęp do
|
||||
modeli, których tu nie ma, a nowe wychodzą szybciej, niż aktualizuje się kod.
|
||||
Puste pole = model domyślny dostawcy.
|
||||
|
||||
Uwaga o pewności danych:
|
||||
* modele **Anthropic** pochodzą z oficjalnej dokumentacji API (okna kontekstu
|
||||
i limity wyjścia zgadzają się z `app/llm/limits.py`);
|
||||
* modele **OpenAI** to podpowiedzi — nie weryfikowałem ich katalogu, więc
|
||||
traktuj je jako wygodę, a nie źródło prawdy;
|
||||
* modele **lokalne** zależą wyłącznie od tego, co masz pobrane w Ollamie/vLLM.
|
||||
|
||||
Katalog można nadpisać/rozszerzyć zmienną `<DOSTAWCA>_MODELS` (lista po przecinku),
|
||||
np. `OPENAI_MODELS="gpt-5,gpt-4o"`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from app.llm.limits import limits_for
|
||||
|
||||
# dostawca -> [(id modelu, krótki opis dla człowieka)]
|
||||
_CATALOG: dict[str, list[tuple[str, str]]] = {
|
||||
"anthropic": [
|
||||
("claude-opus-4-8", "Opus 4.8 — domyślny, bardzo zdolny, 1M kontekstu"),
|
||||
("claude-fable-5", "Fable 5 — najbardziej zdolny, do najtrudniejszych zadań"),
|
||||
("claude-sonnet-5", "Sonnet 5 — szybszy i tańszy, jakość blisko Opusa"),
|
||||
("claude-opus-4-7", "Opus 4.7 — poprzednia generacja Opusa"),
|
||||
("claude-haiku-4-5", "Haiku 4.5 — najszybszy i najtańszy, mniejsze okno"),
|
||||
],
|
||||
"openai": [
|
||||
("gpt-4o-mini", "GPT-4o mini — tani i szybki"),
|
||||
("gpt-4o", "GPT-4o"),
|
||||
("gpt-5", "GPT-5 — jeśli Twoje konto ma dostęp"),
|
||||
("gpt-4.1", "GPT-4.1"),
|
||||
("gpt-4.1-mini", "GPT-4.1 mini"),
|
||||
],
|
||||
"local": [
|
||||
("llama3.1:8b", "Llama 3.1 8B"),
|
||||
("llama3.2", "Llama 3.2"),
|
||||
("qwen2.5", "Qwen 2.5 — większe okno kontekstu"),
|
||||
("mistral", "Mistral"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def models_for(provider: str) -> list[dict]:
|
||||
"""Podpowiedzi modeli dla dostawcy, wraz z oknem kontekstu.
|
||||
|
||||
Okno kontekstu podajemy, bo wprost przekłada się na opcję „maksymalny
|
||||
kontekst modelu" — użytkownik widzi, na ile budżetu promptu może liczyć.
|
||||
"""
|
||||
override = os.getenv(f"{provider.upper()}_MODELS", "").strip()
|
||||
if override:
|
||||
entries = [(m.strip(), "") for m in override.split(",") if m.strip()]
|
||||
else:
|
||||
entries = _CATALOG.get(provider, [])
|
||||
|
||||
out = []
|
||||
for model_id, label in entries:
|
||||
context_window, max_output = limits_for(provider, model_id)
|
||||
out.append({
|
||||
"id": model_id,
|
||||
"label": label or model_id,
|
||||
"context_window": context_window,
|
||||
"max_output": max_output,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def catalog() -> dict[str, list[dict]]:
|
||||
"""Pełny katalog dla UI — jedno żądanie zamiast trzech."""
|
||||
return {provider: models_for(provider) for provider in ("local", "anthropic", "openai")}
|
||||
@@ -82,12 +82,27 @@ def setting(provider: str, suffix: str, fallback: str = "") -> str:
|
||||
return fallback
|
||||
|
||||
|
||||
def build_provider(name: str | None = None) -> LLMProvider:
|
||||
def resolve_model(name: str | None = None, model: str | None = None) -> tuple[str, str]:
|
||||
"""(dostawca, model) BEZ budowania dostawcy — czyli bez wymogu klucza API.
|
||||
|
||||
Rozmiar budżetu promptu zależy tylko od okna kontekstu modelu, więc nie może
|
||||
zależeć od tego, czy klucz jest już skonfigurowany.
|
||||
"""
|
||||
provider = (name or default_provider_name()).lower()
|
||||
if provider not in PROVIDERS:
|
||||
provider = default_provider_name()
|
||||
chosen = (model or "").strip() or setting(provider, "MODEL", _DEFAULT_MODEL[provider])
|
||||
return provider, chosen
|
||||
|
||||
|
||||
def build_provider(name: str | None = None, model: str | None = None) -> LLMProvider:
|
||||
"""Dostawca modelu. `model` z żądania wygrywa nad konfiguracją — użytkownik
|
||||
wybiera model w UI, a konfiguracja podaje tylko wartość domyślną."""
|
||||
name = (name or default_provider_name()).lower()
|
||||
if name not in PROVIDERS:
|
||||
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
||||
|
||||
model = setting(name, "MODEL", _DEFAULT_MODEL[name])
|
||||
model = (model or "").strip() or setting(name, "MODEL", _DEFAULT_MODEL[name])
|
||||
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
|
||||
api_key = setting(name, "API_KEY")
|
||||
|
||||
|
||||
@@ -163,13 +163,12 @@ def chart_prompt(req: PromptRequest) -> dict:
|
||||
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
|
||||
budget_chars = None
|
||||
if req.budget == MAX_BUDGET:
|
||||
from app.llm.factory import build_provider
|
||||
from app.llm.factory import resolve_model
|
||||
from app.llm.limits import prompt_token_budget
|
||||
try:
|
||||
prov = build_provider(req.provider)
|
||||
budget_chars = int(prompt_token_budget(prov.name, prov.model) * CHARS_PER_TOKEN)
|
||||
except Exception: # brak klucza/konfiguracji — zapas z tabeli
|
||||
budget_chars = None
|
||||
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
|
||||
# więc nie może wymagać skonfigurowanego klucza API
|
||||
provider_name, model_name = resolve_model(req.provider, req.model)
|
||||
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
|
||||
|
||||
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
|
||||
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
|
||||
@@ -218,8 +217,7 @@ def chart_prompt(req: PromptRequest) -> dict:
|
||||
|
||||
|
||||
class HoroscopeRequest(PromptRequest):
|
||||
"""Jak PromptRequest + wybór dostawcy modelu (LOG-31)."""
|
||||
provider: str | None = None # local (dom.) | openai | anthropic
|
||||
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
|
||||
max_tokens: int | None = None
|
||||
|
||||
|
||||
@@ -237,7 +235,7 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
|
||||
|
||||
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
||||
try:
|
||||
provider = build_provider(req.provider)
|
||||
provider = build_provider(req.provider, req.model)
|
||||
|
||||
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
|
||||
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
|
||||
@@ -267,6 +265,19 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/llm/models")
|
||||
def llm_models() -> dict:
|
||||
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
||||
|
||||
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
|
||||
dowolny identyfikator, do którego konto ma dostęp.
|
||||
"""
|
||||
from app.llm.catalog import catalog
|
||||
from app.llm.factory import _DEFAULT_MODEL
|
||||
|
||||
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
|
||||
|
||||
|
||||
@app.get("/llm/health")
|
||||
def llm_health(provider: str | None = None) -> dict:
|
||||
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
||||
|
||||
@@ -365,3 +365,43 @@ def test_complete_answer_does_not_loop(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 40000)
|
||||
assert out.text == "Gotowe." and calls["n"] == 1
|
||||
|
||||
|
||||
# ------------------------------------------- wybor modelu przez uzytkownika (UI)
|
||||
|
||||
def test_model_from_request_wins_over_config(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
|
||||
p = factory.build_provider("anthropic", "claude-fable-5")
|
||||
assert p.model == "claude-fable-5", "wybor z UI musi wygrac nad konfiguracja"
|
||||
|
||||
|
||||
def test_blank_model_falls_back_to_configured_default(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-sonnet-5")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
|
||||
assert factory.build_provider("anthropic", " ").model == "claude-sonnet-5"
|
||||
|
||||
|
||||
def test_resolve_model_needs_no_api_key(monkeypatch):
|
||||
"""Budzet promptu zalezy od okna kontekstu modelu — nie moze wymagac klucza.
|
||||
|
||||
Wczesniej liczenie budzetu szlo przez build_provider(), ktory bez klucza
|
||||
rzuca bledem, wiec „maksymalny kontekst" cicho spadal do wartosci zapasowej.
|
||||
"""
|
||||
_clear(monkeypatch)
|
||||
provider, model = factory.resolve_model("anthropic", "claude-haiku-4-5")
|
||||
assert (provider, model) == ("anthropic", "claude-haiku-4-5")
|
||||
with pytest.raises(LLMError): # samo zbudowanie nadal wymaga klucza
|
||||
factory.build_provider("anthropic", "claude-haiku-4-5")
|
||||
|
||||
|
||||
def test_max_budget_differs_between_models(monkeypatch):
|
||||
"""Sedno funkcji: wieksze okno = wiekszy budzet promptu."""
|
||||
from app.llm.limits import prompt_token_budget
|
||||
_clear(monkeypatch)
|
||||
opus = prompt_token_budget(*factory.resolve_model("anthropic", "claude-opus-4-8"))
|
||||
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
||||
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
||||
assert opus > haiku > local > 0
|
||||
|
||||
@@ -69,7 +69,7 @@ class LogicClient:
|
||||
def prompt(
|
||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
||||
provider: str | None = None,
|
||||
provider: str | None = None, model: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
|
||||
|
||||
@@ -78,6 +78,7 @@ class LogicClient:
|
||||
payload: dict[str, Any] = {
|
||||
"profile": profile, "when_utc": when_utc_iso,
|
||||
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
if from_date and to_date:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
@@ -88,7 +89,7 @@ class LogicClient:
|
||||
|
||||
def horoscope(
|
||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", provider: str | None = None,
|
||||
budget: str = "medium", provider: str | None = None, model: str | None = None,
|
||||
from_date: str | None = None, to_date: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
|
||||
@@ -101,6 +102,8 @@ class LogicClient:
|
||||
}
|
||||
if provider:
|
||||
payload["provider"] = provider
|
||||
if model:
|
||||
payload["model"] = model
|
||||
if from_date and to_date:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
|
||||
@@ -108,6 +111,13 @@ class LogicClient:
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def llm_models(self) -> dict[str, Any]:
|
||||
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.get(f"{self.base_url}/llm/models", headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def timeline(
|
||||
self, when_utc_iso: str, lat: float, lon: float,
|
||||
from_date: str, to_date: str, interpret: bool = True,
|
||||
|
||||
@@ -39,6 +39,15 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
return utc.isoformat(), label
|
||||
|
||||
|
||||
def _llm_catalog() -> dict:
|
||||
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
||||
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
||||
try:
|
||||
return logic.llm_models()
|
||||
except httpx.HTTPError:
|
||||
return {"providers": {}, "defaults": {}}
|
||||
|
||||
|
||||
def _logic_error(e: Exception) -> str:
|
||||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||||
return (
|
||||
@@ -53,7 +62,8 @@ def _logic_error(e: Exception) -> str:
|
||||
def chart_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "chart.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog()},
|
||||
)
|
||||
|
||||
|
||||
@@ -115,7 +125,8 @@ def significators_search(
|
||||
def interpret_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "interpret.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog()},
|
||||
)
|
||||
|
||||
|
||||
@@ -131,23 +142,25 @@ def interpret_run(
|
||||
action: str = Form("report"),
|
||||
prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form("local"),
|
||||
llm_model: str = Form(""),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||
"llm_catalog": _llm_catalog()}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
if action == "prompt":
|
||||
ctx["prompt_result"] = logic.prompt(
|
||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
||||
provider=llm_provider,
|
||||
provider=llm_provider, model=llm_model,
|
||||
)
|
||||
elif action == "horoscope":
|
||||
ctx["prompt_result"] = logic.horoscope(
|
||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, provider=llm_provider,
|
||||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||
)
|
||||
else:
|
||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||
@@ -163,7 +176,8 @@ def interpret_run(
|
||||
def timeline_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "timeline.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog()},
|
||||
)
|
||||
|
||||
|
||||
@@ -180,11 +194,13 @@ def timeline_run(
|
||||
action: str = Form("timeline"),
|
||||
prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form("local"),
|
||||
llm_model: str = Form(""),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||
"llm_catalog": _llm_catalog()}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
@@ -192,12 +208,12 @@ def timeline_run(
|
||||
ctx["prompt_result"] = logic.prompt(
|
||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||||
provider=llm_provider,
|
||||
provider=llm_provider, model=llm_model,
|
||||
)
|
||||
elif action == "horoscope":
|
||||
ctx["prompt_result"] = logic.horoscope(
|
||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, provider=llm_provider,
|
||||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||
from_date=from_date, to_date=to_date,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Podpowiedzi modeli zależne od wybranego dostawcy.
|
||||
//
|
||||
// Pole modelu jest CELOWO tekstowe (input + datalist), a nie zamkniętym <select>:
|
||||
// katalog to tylko wygoda, a konto może mieć dostęp do modeli, o których kod nie
|
||||
// wie. Puste pole = model domyślny dostawcy.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const providerEl = document.getElementById('llmProvider');
|
||||
const modelEl = document.getElementById('llmModel');
|
||||
const listEl = document.getElementById('llmModelList');
|
||||
const hintEl = document.getElementById('llmModelHint');
|
||||
const dataEl = document.getElementById('llmCatalog');
|
||||
if (!providerEl || !modelEl || !listEl || !dataEl) return;
|
||||
|
||||
let catalog = {};
|
||||
let defaults = {};
|
||||
try {
|
||||
const parsed = JSON.parse(dataEl.textContent || '{}');
|
||||
catalog = parsed.providers || {};
|
||||
defaults = parsed.defaults || {};
|
||||
} catch (e) {
|
||||
return; // brak katalogu — pole nadal działa jako wolny tekst
|
||||
}
|
||||
|
||||
const fmt = n =>
|
||||
n >= 1000000 ? (n / 1000000) + 'M' : (n >= 1000 ? Math.round(n / 1000) + 'k' : String(n));
|
||||
|
||||
function refresh(resetValue) {
|
||||
const provider = providerEl.value;
|
||||
const models = catalog[provider] || [];
|
||||
|
||||
listEl.innerHTML = '';
|
||||
models.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.label = m.label + ' · kontekst ' + fmt(m.context_window);
|
||||
listEl.appendChild(opt);
|
||||
});
|
||||
|
||||
const fallback = defaults[provider] || '';
|
||||
modelEl.placeholder = fallback ? 'domyślny: ' + fallback : 'domyślny dostawcy';
|
||||
|
||||
// po zmianie dostawcy stary model nie ma sensu (np. gpt-4o u Anthropica)
|
||||
if (resetValue) modelEl.value = '';
|
||||
|
||||
if (hintEl) {
|
||||
const chosen = models.find(m => m.id === modelEl.value.trim());
|
||||
hintEl.textContent = chosen
|
||||
? chosen.label + ' — okno kontekstu ' + fmt(chosen.context_window) +
|
||||
' tokenów, maksymalna odpowiedź ' + fmt(chosen.max_output) + '.'
|
||||
: 'Zostaw puste, by użyć modelu domyślnego. Możesz też wpisać dowolny ' +
|
||||
'identyfikator modelu, do którego Twoje konto ma dostęp.';
|
||||
}
|
||||
}
|
||||
|
||||
providerEl.addEventListener('change', () => refresh(true));
|
||||
modelEl.addEventListener('input', () => refresh(false));
|
||||
refresh(false);
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
{# Katalog modeli wstrzykiwany przez handler — pole jest tekstowe, więc
|
||||
można wpisać dowolny model, do którego konto ma dostęp. #}
|
||||
<script type="application/json" id="llmCatalog">{{ llm_catalog | tojson }}</script>
|
||||
{# Wspólny blok: sterowanie generowaniem + prompt (LOG-29/30) + horoskop (LOG-31).
|
||||
Używany na ekranach Interpretacje i Kalendarz. #}
|
||||
<div class="opts">
|
||||
@@ -11,15 +14,22 @@
|
||||
<option value="max" {{ 'selected' if pb == 'max' else '' }}>maksymalny kontekst modelu</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Model
|
||||
<select name="llm_provider">
|
||||
<label>Dostawca
|
||||
<select name="llm_provider" id="llmProvider">
|
||||
{% set lp = form.llm_provider or 'local' %}
|
||||
<option value="local" {{ 'selected' if lp == 'local' else '' }}>lokalny — nic nie opuszcza sieci</option>
|
||||
<option value="anthropic" {{ 'selected' if lp == 'anthropic' else '' }}>Anthropic — dane wychodzą</option>
|
||||
<option value="openai" {{ 'selected' if lp == 'openai' else '' }}>OpenAI — dane wychodzą</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Model
|
||||
<input type="text" name="llm_model" id="llmModel" list="llmModelList"
|
||||
value="{{ form.llm_model or '' }}" placeholder="domyślny dostawcy"
|
||||
autocomplete="off">
|
||||
<datalist id="llmModelList"></datalist>
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted small" id="llmModelHint"></p>
|
||||
<p class="muted small">
|
||||
Prompt zawiera <strong>oryginalne opisy z baz</strong> oraz dane urodzeniowe. Model lokalny
|
||||
przetwarza je u nas; wybór dostawcy w chmurze oznacza, że ta treść <strong>opuszcza naszą
|
||||
|
||||
@@ -90,4 +90,5 @@
|
||||
|
||||
<script src="/static/now.js"></script>
|
||||
<script src="/static/copy.js"></script>
|
||||
<script src="/static/models.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -80,4 +80,5 @@
|
||||
|
||||
<script src="/static/now.js"></script>
|
||||
<script src="/static/copy.js"></script>
|
||||
<script src="/static/models.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -60,3 +60,52 @@ def test_auth_headers_helper_is_lazy():
|
||||
os.environ["INTERNAL_TOKEN"] = old
|
||||
else:
|
||||
os.environ.pop("INTERNAL_TOKEN", None)
|
||||
|
||||
|
||||
# --------------------------------------------- wybor modelu musi dojsc do logiki
|
||||
# Ta sama klasa bledu co przy tokenie: dokladajac nowa sciezke latwo zapomniec
|
||||
# przekazac parametr, a objaw (cichy powrot do modelu domyslnego) jest niewidoczny.
|
||||
|
||||
def _calls_to(path_fragment: str) -> list[int]:
|
||||
"""Linie wywolan logic.<metoda>(...) w handlerach prezentacji."""
|
||||
main = CLIENT.parent.parent / "main.py"
|
||||
tree = ast.parse(main.read_text(encoding="utf-8"))
|
||||
out = []
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == path_fragment
|
||||
and isinstance(node.func.value, ast.Name) and node.func.value.id == "logic"):
|
||||
out.append(node.lineno)
|
||||
return out
|
||||
|
||||
|
||||
def _has_kwarg(main_src: str, lineno: int, name: str) -> bool:
|
||||
tree = ast.parse(main_src)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and node.lineno == lineno:
|
||||
return any(kw.arg == name for kw in node.keywords)
|
||||
return False
|
||||
|
||||
|
||||
def test_every_llm_call_passes_selected_model():
|
||||
main = CLIENT.parent.parent / "main.py"
|
||||
src = main.read_text(encoding="utf-8")
|
||||
missing = []
|
||||
for method in ("prompt", "horoscope"):
|
||||
for line in _calls_to(method):
|
||||
if not _has_kwarg(src, line, "model"):
|
||||
missing.append(f"main.py:{line} logic.{method}()")
|
||||
assert not missing, (
|
||||
"Wywołania bez wybranego modelu — po cichu użyją domyślnego: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def test_every_llm_call_passes_provider():
|
||||
main = CLIENT.parent.parent / "main.py"
|
||||
src = main.read_text(encoding="utf-8")
|
||||
missing = []
|
||||
for method in ("prompt", "horoscope"):
|
||||
for line in _calls_to(method):
|
||||
if not _has_kwarg(src, line, "provider"):
|
||||
missing.append(f"main.py:{line} logic.{method}()")
|
||||
assert not missing, "Wywołania bez dostawcy: " + ", ".join(missing)
|
||||
|
||||
Reference in New Issue
Block a user