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