diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 8b14535..b32c04f 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -121,6 +121,86 @@ def chart_report(req: ReportRequest) -> dict: return {"engine": engine.name, **report} +class PromptRequest(BaseModel): + """Wejście generatora promptu (LOG-29/30).""" + profile: str = "natal" # natal | period + when_utc: datetime + lat: float = 0.0 + lon: float = 0.0 + budget: str = "medium" # concise | medium | extensive + limit: int = 5000 + # tylko dla profilu period: + from_date: str | None = None + to_date: str | None = None + techniques: list[str] | None = None + + +@app.post("/chart/prompt") +def chart_prompt(req: PromptRequest) -> dict: + """Gotowy prompt do LLM z naszych wyliczeń (LOG-29) z budżetowaniem (LOG-30). + + profile=natal → horoskop urodzeniowy (ekran Interpretacje) + profile=period → horoskop na wybrany okres (ekran Kalendarz) + + Nie woła żadnego modelu — zwraca sam prompt i statystyki redukcji, żeby dało się + go obejrzeć i skopiować. Wysyłkę do modelu doda LOG-31. + """ + from app.engine.chart import build_chart + from app.engine.models import ChartMoment + from app.prompt import build_natal_prompt, build_period_prompt + + engine = get_engine() + moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) + chart = build_chart(engine, moment) + label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC") + data_error = None + + # 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. + try: + if req.profile == "natal": + from app.significators import build_report + + report: dict = {"objects": []} + try: + report = build_report( + chart["positions"], DataClient(), + aspects=chart.get("aspects"), per_object_limit=req.limit, + ) + except httpx.HTTPError as e: + data_error = f"Warstwa danych niedostępna: {e}" + out = build_natal_prompt(chart, report, req.budget, label) + + elif req.profile == "period": + if not (req.from_date and req.to_date): + raise HTTPException(422, "profile=period wymaga from_date i to_date") + from app.engine import houses as H + from app.engine.timeline import build_timeline + from app.significators import interpret_events + + ramc, eps = engine.sidereal(moment) + points = {"Asc": H.compute_asc(ramc, eps, moment.lat), "MC": H.compute_mc(ramc, eps)} + for p in engine.positions(moment): + points[p.name] = p.longitude + events = build_timeline(engine, moment, points, req.from_date, req.to_date, + req.techniques) + try: + interpret_events(events, DataClient()) + except httpx.HTTPError as e: + data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje + out = build_period_prompt(chart, events, req.from_date, req.to_date, + req.budget, label) + else: + raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)") + except ValueError as e: # nieznany budżet + raise HTTPException(422, str(e)) + + out["engine"] = engine.name + if data_error: + out["data_error"] = data_error + return out + + class ProfectionsRequest(BaseModel): when_utc: datetime # moment urodzenia (UTC) lat: float = 0.0 diff --git a/services/logic/app/prompt.py b/services/logic/app/prompt.py new file mode 100644 index 0000000..d3888ba --- /dev/null +++ b/services/logic/app/prompt.py @@ -0,0 +1,339 @@ +"""Generator promptów do LLM (LOG-29) + budżetowanie rozmiaru (LOG-30). + +Składa z naszych wyliczeń gotowe, profesjonalne polecenie po polsku: + - profil **natal** — horoskop urodzeniowy (na bazie /chart/report), + - profil **period** — horoskop na wybrany okres (na bazie /chart/timeline). + +Zasada naczelna promptu: model ma pisać WYŁĄCZNIE na podstawie dostarczonych danych +i przy każdej tezie wskazać konkretny sygnifikator, z którego ona wynika. To odróżnia +wynik od ogólnikowej wróżby i pozwala go zweryfikować. + +Budżetowanie (LOG-30) — kolejność redukcji: + 1. deduplikacja (ten sam sygnifikator ORAZ ten sam opis), + 2. grupowanie identycznych opisów z licznikiem wystąpień, + 3. sortowanie malejąco wg punktacji siły (LOG-21), + 4. obcięcie ogona do budżetu — jednostką obcięcia jest CAŁE wskazanie, + 5. skracanie nadmiernie długich opisów (z jawnym oznaczeniem). +Zawsze raportujemy, ile wskazań weszło i ile pominięto — użytkownik ma wiedzieć, +czego brakuje, i móc zwiększyć budżet. + +Generowanie jest deterministyczne: ten sam horoskop + ten sam budżet = ten sam prompt. +""" +from __future__ import annotations + +import re + +# budżety w znakach (całego promptu); dobrane pod wklejanie do ChatGPT/Claude +BUDGETS: dict[str, int] = { + "concise": 4_000, + "medium": 12_000, + "extensive": 30_000, +} +DEFAULT_BUDGET = "medium" + +MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji) +CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI + +DISCLAIMER = ( + "Zakończ krótką notą: treść jest interpretacją astrologiczną i nie stanowi porady " + "medycznej, prawnej ani finansowej." +) + + +def _norm(text: str) -> str: + return re.sub(r"\s+", " ", (text or "").strip().lower()) + + +def est_tokens(text: str) -> int: + return int(len(text) / CHARS_PER_TOKEN) + + +def _shorten(text: str, limit: int = MAX_EFFECT_CHARS) -> tuple[str, bool]: + text = (text or "").strip() + if len(text) <= limit: + return text, False + return text[:limit].rstrip() + " […skrócono]", True + + +# ----------------------------------------------------------------- wskazania + +def natal_indications(report: dict) -> list[dict]: + """Płaska lista wskazań z raportu natalnego: obiekt + faseta + opis + waga.""" + out: list[dict] = [] + for obj in report.get("objects") or []: + name = obj.get("object") + for facet in obj.get("facets") or []: + score = float(facet.get("score") or 0.0) + for s in facet.get("samples") or []: + out.append({ + "context": f"{name} — {facet.get('label')}", + "sort_key": (name or "", str(facet.get("label") or "")), + "score": score, + "significator": s.get("expanded") or s.get("significator") or "", + "effect": s.get("effect") or "", + }) + return out + + +def period_indications(events: list[dict]) -> list[dict]: + """Wskazania z osi czasu — waga rośnie z liczbą trafień w bazie.""" + out: list[dict] = [] + for ev in events or []: + head = f"{ev.get('technique')} · {ev.get('significator')} · {ev.get('exact')}" + total = float(ev.get("interpretations_count") or 0) + for s in ev.get("interpretations") or []: + out.append({ + "context": head, + "sort_key": (str(ev.get("exact") or ""), str(ev.get("technique") or "")), + "score": total, + "significator": s.get("expanded") or s.get("significator") or "", + "effect": s.get("effect") or "", + "date": ev.get("exact"), + "start": ev.get("start"), + "end": ev.get("end"), + "technique": ev.get("technique"), + }) + return out + + +def reduce_indications(items: list[dict], budget_chars: int) -> tuple[list[dict], dict]: + """Dedup → grupowanie → sortowanie wg wagi → obcięcie do budżetu. + + Zwraca (wybrane wskazania, statystyki). Jednostką obcięcia jest całe wskazanie. + """ + # 1+2: dedup i grupowanie identycznych (kontekst, opis) z licznikiem + grouped: dict[tuple[str, str], dict] = {} + order: list[tuple[str, str]] = [] + for it in items: + key = (_norm(it["context"]), _norm(it["effect"])) + g = grouped.get(key) + if g is None: + g = {**it, "count": 0, "significators": []} + grouped[key] = g + order.append(key) + g["count"] += 1 + if it["significator"] not in g["significators"]: + g["significators"].append(it["significator"]) + merged = [grouped[k] for k in order] + deduped = len(items) - len(merged) + + # 3: sortowanie malejąco wg wagi; remis rozstrzygany deterministycznie + merged.sort(key=lambda g: (-g["score"], -g["count"], g["sort_key"], g["significator"])) + + # 5 (przed obcięciem, bo wpływa na rozmiar): skracanie długich opisów + shortened = 0 + for g in merged: + g["effect"], was = _shorten(g["effect"]) + shortened += 1 if was else 0 + + # 4: obcięcie ogona do budżetu — cała pozycja albo nic + chosen: list[dict] = [] + used = 0 + for g in merged: + cost = len(_render_indication(g)) + 1 + if used + cost > budget_chars and chosen: + break + chosen.append(g) + used += cost + + omitted = len(merged) - len(chosen) + stats = { + "source_rows": len(items), + "after_grouping": len(merged), + "deduplicated": deduped, + "included": len(chosen), + "omitted": omitted, + "shortened": shortened, + "min_score_included": round(chosen[-1]["score"], 3) if chosen else None, + "max_score_omitted": round(merged[len(chosen)]["score"], 3) if omitted else None, + } + return chosen, stats + + +def _render_indication(g: dict) -> str: + times = f" (×{g['count']})" if g.get("count", 1) > 1 else "" + sig = g.get("significator") or "" + return f"- {sig} → {g.get('effect')}{times}" + + +def _render_grouped(chosen: list[dict]) -> str: + """Renderuje wskazania pogrupowane po kontekście, zachowując kolejność wagi.""" + blocks: list[str] = [] + seen: dict[str, list[dict]] = {} + order: list[str] = [] + for g in chosen: + ctx = g["context"] + if ctx not in seen: + seen[ctx] = [] + order.append(ctx) + seen[ctx].append(g) + for ctx in order: + rows = seen[ctx] + blocks.append(f"## {ctx} [waga {rows[0]['score']:.2f}]") + blocks.extend(_render_indication(g) for g in rows) + blocks.append("") + return "\n".join(blocks).rstrip() + + +# ------------------------------------------------------------- sekcje danych + +def _chart_section(chart: dict, moment_label: str | None) -> str: + lines: list[str] = ["# DANE HOROSKOPU"] + meta = [] + if moment_label: + meta.append(f"moment: {moment_label}") + if chart.get("zodiac"): + z = chart["zodiac"] + if chart.get("ayanamsha") is not None: + z += f" (ayanamsa {chart['ayanamsha']:.4f}°)" + meta.append(f"zodiak: {z}") + if chart.get("house_system"): + meta.append(f"system domów: {chart['house_system']}") + if chart.get("sect"): + meta.append(f"sekta: {'dzienna' if chart['sect'] == 'day' else 'nocna'}") + if meta: + lines.append(" · ".join(meta)) + + if chart.get("positions"): + lines.append("\n## Pozycje") + for p in chart["positions"]: + house = f"dom {p['house']}" if p.get("house") else "—" + lines.append(f"{p['name']:<12} {p.get('in_sign',''):<16} {house:<8} {p.get('direction','')}") + + angles = chart.get("angles") or {} + if angles: + lines.append("\n## Osie") + for key in ("Asc", "MC", "Dsc", "IC"): + a = angles.get(key) + if a: + lines.append(f"{a['name']:<5} {a.get('in_sign','')}") + + if chart.get("lots"): + lines.append("\n## Lots (punkty arabskie)") + for lot in chart["lots"]: + lines.append( + f"{lot['name']:<10} {lot.get('in_sign',''):<16} dom {lot.get('house','—')}" + f" ({lot.get('formula','')})" + ) + + if chart.get("aspects"): + lines.append("\n## Aspekty (orb; A = aplikacyjny, S = separacyjny)") + for a in chart["aspects"]: + mark = a.get("as") or "" + lines.append( + f"{a['obj1']} {a['aspect']} {a['obj2']} orb {a.get('orb', 0):.2f}° {mark}" + ) + return "\n".join(lines) + + +def _indications_section(chosen: list[dict], stats: dict) -> str: + if not chosen: + return ( + "# WSKAZANIA Z BAZ\n" + "(brak trafień w bazach dla tego horoskopu — oprzyj interpretację wyłącznie " + "na danych horoskopu powyżej)" + ) + head = [ + "# WSKAZANIA Z BAZ INTERPRETACYJNYCH", + "Wskazania dopasowane do tego horoskopu, uporządkowane od najsilniejszych.", + "Zapis: `- sygnifikator → opis (×ile razy wystąpiło w bazach)`.", + ] + if stats.get("omitted"): + head.append( + f"UWAGA: pokazano {stats['included']} najsilniejszych wskazań, pominięto " + f"{stats['omitted']} słabszych (limit długości)." + ) + return "\n".join(head) + "\n\n" + _render_grouped(chosen) + + +# ------------------------------------------------------------------ prompty + +_NATAL_TASK = """# ZADANIE +Jesteś doświadczonym astrologiem. Napisz profesjonalną interpretację HOROSKOPU +URODZENIOWEGO po polsku, opierając się wyłącznie na danych podanych niżej.""" + +_PERIOD_TASK = """# ZADANIE +Jesteś doświadczonym astrologiem. Napisz profesjonalną prognozę astrologiczną +NA WYBRANY OKRES po polsku, opierając się wyłącznie na danych podanych niżej.""" + +_NATAL_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ +1. Struktura: (a) portret ogólny, (b) temperament i sekta, (c) obszary życia według domów, + (d) napięcia i wyzwania, (e) zasoby i mocne strony, (f) zwięzłe podsumowanie. +2. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie, np. „(Moon in 12th house)". + Teza bez wskazania jest niedopuszczalna. +3. Nie dodawaj twierdzeń, których nie da się wywieść z powyższych danych. Nie zmyślaj + pozycji, aspektów ani wskazań; nie korzystaj z wiedzy spoza tego promptu. +4. Gdy wskazania są sprzeczne, powiedz to wprost i wskaż obie strony, zamiast wybierać jedną. +5. Wagę wskazania traktuj jako siłę świadectwa — mocniejsze mają pierwszeństwo w syntezie. +6. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych.""" + +_PERIOD_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ +1. Uporządkuj prognozę CHRONOLOGICZNIE; przy każdym okresie podaj daty (start / dokładna / koniec). +2. Dla każdej daty napisz, która technika ją wyznacza (profekcja, solariusz, dyrekcja solar-arc, + Firdaria) i co z niej wynika. +3. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie. Teza bez wskazania jest + niedopuszczalna. +4. Nie zmyślaj dat ani zdarzeń spoza podanych. Nie korzystaj z wiedzy spoza tego promptu. +5. Rozróżniaj okresy o mocnym świadectwie (wysoka waga, kilka technik zbieżnych w czasie) + od słabych — i powiedz wprost, które są które. +6. Na końcu dodaj krótkie zestawienie: najważniejsze okresy w kolejności ważności. +7. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych.""" + + +def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str: + return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"]) + + +def _budget_chars(budget: str) -> int: + if budget not in BUDGETS: + raise ValueError( + f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})" + ) + return BUDGETS[budget] + + +def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) -> dict: + stats = { + **stats, + "profile": profile, + "budget": budget, + "limit_chars": limit, + "chars": len(prompt), + "est_tokens": est_tokens(prompt), + } + return {"profile": profile, "prompt": prompt, "stats": stats} + + +def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET, + moment_label: str | None = None) -> dict: + """Prompt na horoskop urodzeniowy (ekran „Interpretacje").""" + limit = _budget_chars(budget) + chart_sec = _chart_section(chart, moment_label) + fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200 + chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500)) + prompt = _assemble(_NATAL_TASK, chart_sec, _indications_section(chosen, stats), _NATAL_OUTPUT) + return _finish(prompt, budget, limit, stats, "natal") + + +def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str, + budget: str = DEFAULT_BUDGET, moment_label: str | None = None) -> dict: + """Prompt na horoskop okresowy (ekran „Kalendarz").""" + limit = _budget_chars(budget) + chart_sec = _chart_section(chart, moment_label) + task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**." + + ev_lines = ["# OŚ CZASU (techniki predykcyjne)", + "Zapis: technika · sygnifikator · start → dokładna → koniec."] + for ev in events or []: + ev_lines.append( + f"- {ev.get('technique')} · {ev.get('significator')} · " + f"{ev.get('start')} → {ev.get('exact')} → {ev.get('end')}" + ) + events_sec = "\n".join(ev_lines) + + fixed = len(task) + len(chart_sec) + len(events_sec) + len(_PERIOD_OUTPUT) + len(DISCLAIMER) + 200 + chosen, stats = reduce_indications(period_indications(events), max(limit - fixed, 500)) + body = events_sec + "\n\n" + _indications_section(chosen, stats) + prompt = _assemble(task, chart_sec, body, _PERIOD_OUTPUT) + stats["events"] = len(events or []) + return _finish(prompt, budget, limit, stats, "period") diff --git a/services/logic/tests/test_prompt.py b/services/logic/tests/test_prompt.py new file mode 100644 index 0000000..6a6638d --- /dev/null +++ b/services/logic/tests/test_prompt.py @@ -0,0 +1,218 @@ +"""Generator promptów (LOG-29) i budżetowanie (LOG-30). + +Testy nie wołają żadnego modelu — sprawdzają skład promptu i niezmienniki redukcji. +""" +import pytest + +from app.prompt import ( + BUDGETS, + build_natal_prompt, + build_period_prompt, + natal_indications, + period_indications, + reduce_indications, +) + +CHART = { + "zodiac": "tropical", + "house_system": "whole_sign", + "sect": "day", + "positions": [ + {"name": "Sun", "sign": "Taurus", "in_sign": "Tau 10°12'37\"", "house": 11, "direction": "D"}, + {"name": "Moon", "sign": "Aries", "in_sign": "Ari 7°48'40\"", "house": 10, "direction": "D"}, + ], + "angles": {"Asc": {"name": "Asc", "in_sign": "Can 16°00'54\""}, + "MC": {"name": "MC", "in_sign": "Pis 25°50'50\""}}, + "lots": [{"name": "Fortune", "in_sign": "Can 7°15'14\"", "house": 1, + "formula": "Asc + Moon − Sun"}], + "aspects": [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.34, "as": "A"}], +} + + +def _report(n_samples=3, score=2.0): + return {"objects": [{ + "object": "Sun", "sign": "Taurus", "house": 11, + "facets": [{ + "type": "sign", "label": "w znaku Taurus", "score": score, + "samples": [{"significator": f"[Su in [Tau {i}", "expanded": f"Sun in Taurus {i}", + "effect": f"efekt numer {i}"} for i in range(n_samples)], + }], + }]} + + +# ---------------------------------------------------------------- budżetowanie + +def test_budget_limits_prompt_size(): + # opisy MUSZĄ być różne — identyczne zlałoby grupowanie w jedną pozycję + big = {"objects": [{ + "object": "Sun", "sign": "Taurus", "house": 11, + "facets": [{"type": "sign", "label": "w znaku Taurus", "score": 1.0, + "samples": [{"significator": f"[Su {i}", "expanded": f"Sun {i}", + "effect": f"opis {i} " + "x" * 200} for i in range(500)]}], + }]} + out = build_natal_prompt(CHART, big, budget="concise") + assert out["stats"]["chars"] <= BUDGETS["concise"] * 1.1 # z marginesem na sekcje stałe + assert out["stats"]["omitted"] > 0 + + +def test_bigger_budget_includes_more(): + big = _report(n_samples=300, score=1.0) + small = build_natal_prompt(CHART, big, budget="concise")["stats"] + large = build_natal_prompt(CHART, big, budget="extensive")["stats"] + assert large["included"] > small["included"] + assert large["omitted"] < small["omitted"] + + +def test_grouping_merges_identical_effects(): + items = [ + {"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0, + "significator": "Sun in Taurus", "effect": "ten sam opis"}, + {"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0, + "significator": "Sun in Taurus (inny zapis)", "effect": "Ten Sam Opis "}, + ] + chosen, stats = reduce_indications(items, 10_000) + assert len(chosen) == 1 # zlane w jedno + assert chosen[0]["count"] == 2 # z licznikiem wystąpień + assert stats["after_grouping"] == 1 + assert stats["deduplicated"] == 1 + + +def test_sorted_by_score_desc(): + items = [ + {"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i), + "significator": f"s{i}", "effect": f"e{i}"} for i in range(5) + ] + chosen, _ = reduce_indications(items, 10_000) + scores = [c["score"] for c in chosen] + assert scores == sorted(scores, reverse=True) + + +def test_weakest_are_dropped_first(): + items = [ + {"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i), + "significator": f"s{i}", "effect": "opis " * 20} for i in range(20) + ] + chosen, stats = reduce_indications(items, 400) + assert stats["omitted"] > 0 + # to, co weszło, ma wagę nie niższą niż to, co odpadło + assert stats["min_score_included"] >= stats["max_score_omitted"] + + +def test_long_effects_are_shortened(): + items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0, + "significator": "s", "effect": "x" * 1000}] + chosen, stats = reduce_indications(items, 10_000) + assert stats["shortened"] == 1 + assert "skrócono" in chosen[0]["effect"] + + +def test_never_truncates_mid_indication(): + items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0, + "significator": "s", "effect": "opis " * 50} for _ in range(10)] + chosen, _ = reduce_indications(items, 200) + assert chosen # zawsze co najmniej jedno całe + for c in chosen: + assert c["effect"].endswith(("opis", "[…skrócono]")) # nie urwane w pół słowa + + +def test_unknown_budget_rejected(): + with pytest.raises(ValueError): + build_natal_prompt(CHART, _report(), budget="gigantyczny") + + +# ----------------------------------------------------------------- skład promptu + +def test_natal_prompt_contains_required_sections(): + p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"] + for section in ["# ZADANIE", "# DANE HOROSKOPU", "# WSKAZANIA Z BAZ", + "# JAK MA WYGLĄDAĆ ODPOWIEDŹ", "# ZASTRZEŻENIE"]: + assert section in p + assert "HOROSKOPU\nURODZENIOWEGO" in p or "URODZENIOWEGO" in p + + +def test_natal_prompt_carries_chart_data(): + p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"] + assert "Tau 10°12'37\"" in p and "dom 11" in p # pozycja + dom + assert "Can 16°00'54\"" in p # Asc + assert "Fortune" in p # Lots + assert "Sun conjunction Moon" in p or "conjunction" in p + assert "sekta: dzienna" in p + assert "1984-04-30 09:20 UTC" in p + + +def test_prompt_demands_citing_significators(): + p = build_natal_prompt(CHART, _report())["prompt"] + assert "Teza bez wskazania jest niedopuszczalna" in p + assert "Nie zmyślaj" in p + + +def test_prompt_has_disclaimer(): + p = build_natal_prompt(CHART, _report())["prompt"] + assert "nie stanowi porady" in p + + +def test_prompt_is_deterministic(): + a = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"] + b = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"] + assert a == b + + +def test_omission_is_reported_in_prompt(): + out = build_natal_prompt(CHART, _report(n_samples=400, score=1.0), budget="concise") + assert out["stats"]["omitted"] > 0 + assert "pominięto" in out["prompt"] # użytkownik/model wie, że coś odpadło + + +def test_empty_report_still_builds_prompt(): + out = build_natal_prompt(CHART, {"objects": []}) + assert out["stats"]["included"] == 0 + assert "brak trafień" in out["prompt"] + assert "# DANE HOROSKOPU" in out["prompt"] + + +# ------------------------------------------------------------------- okresowy + +EVENTS = [ + {"technique": "profection", "significator": "Lord of Year: Mars", "start": "2026-04-30", + "exact": "2026-04-30", "end": "2027-04-30", "interpretations_count": 2, + "interpretations": [{"significator": "[Ma", "expanded": "Mars", "effect": "opis marsowy"}]}, + {"technique": "solar_arc", "significator": "Sun conj Saturn", "start": "2026-01-01", + "exact": "2026-06-15", "end": "2026-12-31", "interpretations_count": 1, + "interpretations": [{"significator": "[Su [conj [Sa", "expanded": "Sun conjunction Saturn", + "effect": "opis saturniczny"}]}, +] + + +def test_period_prompt_has_dates_and_timeline(): + out = build_period_prompt(CHART, EVENTS, "2026-01-01", "2027-01-01") + p = out["prompt"] + assert "2026-01-01 — 2027-01-01" in p + assert "# OŚ CZASU" in p + assert "profection" in p and "solar_arc" in p + assert "2026-06-15" in p # data dokładna zdarzenia + assert "CHRONOLOGICZNIE" in p + assert out["stats"]["events"] == 2 + assert out["profile"] == "period" + + +def test_period_prompt_keeps_timeline_without_interpretations(): + """Padnięta warstwa danych zabiera wskazania, ale NIE oś czasu — ta jest czysto + obliczeniowa i bez niej prognoza okresowa jest bezużyteczna.""" + bare = [{k: v for k, v in ev.items() + if k not in ("interpretations", "interpretations_count")} for ev in EVENTS] + out = build_period_prompt(CHART, bare, "2026-01-01", "2027-01-01") + assert out["stats"]["events"] == 2 + assert "profection" in out["prompt"] and "2026-06-15" in out["prompt"] + assert out["stats"]["included"] == 0 # brak wskazań, ale oś czasu jest + + +def test_period_indications_weight_by_hit_count(): + items = period_indications(EVENTS) + assert [i["score"] for i in items] == [2.0, 1.0] + + +def test_natal_indications_flatten_facets(): + items = natal_indications(_report(n_samples=4)) + assert len(items) == 4 + assert all(i["context"].startswith("Sun —") for i in items) + assert all(i["score"] == 2.0 for i in items) diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index 76abcd3..4ed257e 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -59,6 +59,22 @@ class LogicClient: r.raise_for_status() return r.json() + 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, + ) -> dict[str, Any]: + """Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.""" + payload: dict[str, Any] = { + "profile": profile, "when_utc": when_utc_iso, + "lat": lat, "lon": lon, "budget": budget, + } + if from_date and to_date: + payload["from_date"], payload["to_date"] = from_date, to_date + with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client: + r = client.post(f"{self.base_url}/chart/prompt", json=payload) + 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 39dbf51..637f5b1 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -127,14 +127,21 @@ def interpret_run( lat: float = Form(0.0), lon: float = Form(0.0), group: bool = Form(False), + action: str = Form("report"), + prompt_budget: str = Form("medium"), ): form = {"date": date, "time": time, "tz_offset": tz_offset, - "lat": lat, "lon": lon, "group": group} + "lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget} ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label - ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group) + if action == "prompt": + ctx["prompt_result"] = logic.prompt( + profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, + ) + else: + ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group) except httpx.HTTPError as e: ctx["error"] = _logic_error(e) except ValueError as e: @@ -161,17 +168,25 @@ def timeline_run( lon: float = Form(0.0), from_date: str = Form(...), to_date: str = Form(...), + action: str = Form("timeline"), + prompt_budget: str = Form("medium"), ): form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, - "from_date": from_date, "to_date": to_date} + "from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget} ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label - ctx["result"] = logic.timeline( - when_utc_iso=iso_utc, lat=lat, lon=lon, - from_date=from_date, to_date=to_date, interpret=True, - ) + if action == "prompt": + 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, + ) + else: + ctx["result"] = logic.timeline( + when_utc_iso=iso_utc, lat=lat, lon=lon, + from_date=from_date, to_date=to_date, interpret=True, + ) except httpx.HTTPError as e: ctx["error"] = _logic_error(e) except ValueError as e: diff --git a/services/presentation/app/static/copy.js b/services/presentation/app/static/copy.js new file mode 100644 index 0000000..77e7a5e --- /dev/null +++ b/services/presentation/app/static/copy.js @@ -0,0 +1,31 @@ +// Kopiowanie do schowka dla przycisków [data-copy="#selektor"]. +// clipboard API wymaga secure context (https/localhost), a aplikacja bywa serwowana +// po http:// — dlatego jest awaryjne przejście na zaznaczenie + execCommand. +document.addEventListener('click', function (e) { + const btn = e.target.closest('[data-copy]'); + if (!btn) return; + + const src = document.querySelector(btn.getAttribute('data-copy')); + if (!src) return; + + const done = ok => { + const label = btn.textContent; + btn.textContent = ok ? 'Skopiowano ✓' : 'Nie udało się — zaznacz i skopiuj ręcznie'; + setTimeout(() => { btn.textContent = label; }, 2500); + }; + + const text = src.value !== undefined ? src.value : src.textContent; + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => done(true), () => done(false)); + return; + } + // awaryjnie: zaznacz zawartość i spróbuj starym poleceniem + try { + src.focus(); + if (src.select) src.select(); + done(document.execCommand('copy')); + } catch (err) { + done(false); + } +}); diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index 3e719c9..20935c4 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -81,3 +81,10 @@ tr:last-child td { border-bottom: none; } .geo-pin { font-size: 20px; line-height: 24px; text-align: center; } /* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */ .leaflet-control-attribution { font-size: .68rem; } + +/* Generator promptu do LLM (LOG-29/30) */ +textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box; + background: #12132a; color: var(--ink); border: 1px solid var(--line); + border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; } +textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; } diff --git a/services/presentation/app/templates/_prompt_block.html b/services/presentation/app/templates/_prompt_block.html new file mode 100644 index 0000000..d03a484 --- /dev/null +++ b/services/presentation/app/templates/_prompt_block.html @@ -0,0 +1,40 @@ +{# Wspólny blok: sterowanie budżetem + wynik generatora promptu (LOG-29/30). + Używany na ekranach Interpretacje i Kalendarz. #} +
+ +
+ +{% if prompt_result %} + {% set st = prompt_result.stats %} +
+ Prompt gotowy · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) · + budżet: {{ st.budget }} · + wskazań: {{ st.included }} + {% if st.omitted %}· pominięto: {{ st.omitted }}{% endif %} + {% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %} +
+ + {% if st.omitted %} +

+ Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}). + Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie. +

+ {% endif %} + + {% if prompt_result.data_error %} +
{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.
+ {% endif %} + +
+ + Wklej do ChatGPT lub Claude. +
+ +{% endif %} diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html index c3b0c40..2ebe338 100644 --- a/services/presentation/app/templates/interpret.html +++ b/services/presentation/app/templates/interpret.html @@ -28,9 +28,11 @@ {% if location_label %}

Wstępnie wpisano lokalizację: {{ location_label }} ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".

{% endif %} + {% include "_prompt_block.html" %}
- + +
@@ -86,4 +88,5 @@ {% endif %} + {% endblock %} diff --git a/services/presentation/app/templates/timeline.html b/services/presentation/app/templates/timeline.html index 966f1ef..e1b5036 100644 --- a/services/presentation/app/templates/timeline.html +++ b/services/presentation/app/templates/timeline.html @@ -33,9 +33,11 @@ + {% include "_prompt_block.html" %}
- + +
@@ -76,4 +78,5 @@ {% endif %} + {% endblock %}