diff --git a/services/logic/app/llm/catalog.py b/services/logic/app/llm/catalog.py new file mode 100644 index 0000000..31f408b --- /dev/null +++ b/services/logic/app/llm/catalog.py @@ -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ą `_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")} diff --git a/services/logic/app/llm/factory.py b/services/logic/app/llm/factory.py index afad1c4..963bf00 100644 --- a/services/logic/app/llm/factory.py +++ b/services/logic/app/llm/factory.py @@ -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") diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 64ba324..9f88a5a 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -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).""" diff --git a/services/logic/tests/test_llm.py b/services/logic/tests/test_llm.py index 5ae6bff..25659b5 100644 --- a/services/logic/tests/test_llm.py +++ b/services/logic/tests/test_llm.py @@ -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 diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index 7aba5b5..7df6731 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -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, diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index eedaaf6..182b548 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -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: diff --git a/services/presentation/app/static/models.js b/services/presentation/app/static/models.js new file mode 100644 index 0000000..f4f385f --- /dev/null +++ b/services/presentation/app/static/models.js @@ -0,0 +1,58 @@ +// Podpowiedzi modeli zależne od wybranego dostawcy. +// +// Pole modelu jest CELOWO tekstowe (input + datalist), a nie zamkniętym - + +

Prompt zawiera oryginalne opisy z baz oraz dane urodzeniowe. Model lokalny przetwarza je u nas; wybór dostawcy w chmurze oznacza, że ta treść opuszcza naszą diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html index f514fa2..584c4d5 100644 --- a/services/presentation/app/templates/interpret.html +++ b/services/presentation/app/templates/interpret.html @@ -90,4 +90,5 @@ + {% endblock %} diff --git a/services/presentation/app/templates/timeline.html b/services/presentation/app/templates/timeline.html index 4ac208a..fbe01e5 100644 --- a/services/presentation/app/templates/timeline.html +++ b/services/presentation/app/templates/timeline.html @@ -80,4 +80,5 @@ + {% endblock %} diff --git a/services/presentation/tests/test_client_auth.py b/services/presentation/tests/test_client_auth.py index 2ea038b..e29d6cc 100644 --- a/services/presentation/tests/test_client_auth.py +++ b/services/presentation/tests/test_client_auth.py @@ -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.(...) 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)