feat(logic): generator promptow do LLM + budzetowanie (LOG-29, LOG-30)
Pierwszy krok ustalonej kolejnosci: prompt z podgladem, uzyteczny od razu BEZ integracji API (ta przyjdzie w LOG-31). LOG-29 — app/prompt.py: - profil natal (ekran Interpretacje) i period (ekran Kalendarz), - prompt po polsku: zadanie -> dane horoskopu (pozycje, osie, Lots, aspekty, sekta, zodiak) -> wskazania z baz wg wagi -> instrukcje -> zastrzezenie, - twarde reguly: kazda teza musi cytowac konkretny sygnifikator, zakaz wychodzenia poza dostarczone dane, jawne wskazanie sprzecznosci, - deterministyczny: ten sam horoskop + budzet = ten sam prompt. LOG-30 — redukcja do budzetu (concise/medium/extensive): dedup -> grupowanie z licznikiem -> sortowanie wg punktacji sily (LOG-21) -> obciecie ogona (jednostka = CALE wskazanie) -> skracanie dlugich opisow. Statystyki zwracaja ile weszlo/pominieto i jaki byl prog — takze w tresci promptu, zeby model wiedzial, ze widzi wybor. POST /chart/prompt — zwraca sam prompt + statystyki, bez wolania modelu. Prezentacja: przycisk „Generuj prompt (AI)" na obu ekranach, wybor budzetu, pole z promptem + kopiowanie (z fallbackiem dla http bez secure context). WAZNE (znalezione przy tescie e2e): padnieta warstwa danych zabiera tylko wskazania — horoskop i OS CZASU zostaja, bo sa czysto obliczeniowe. Wczesniej blad bazy gubil cala osie czasu, czyniac prognoze okresowa bezuzyteczna. Zabezpieczone testem. Testy: 19 nowych, calosc 118 passed / 1 skipped. Zweryfikowane e2e w przegladarce: profil natal (3340 znakow) i period (15 zdarzen, 4728 znakow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #9.
This commit is contained in:
@@ -121,6 +121,86 @@ def chart_report(req: ReportRequest) -> dict:
|
|||||||
return {"engine": engine.name, **report}
|
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):
|
class ProfectionsRequest(BaseModel):
|
||||||
when_utc: datetime # moment urodzenia (UTC)
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -59,6 +59,22 @@ class LogicClient:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
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(
|
def timeline(
|
||||||
self, when_utc_iso: str, lat: float, lon: float,
|
self, when_utc_iso: str, lat: float, lon: float,
|
||||||
from_date: str, to_date: str, interpret: bool = True,
|
from_date: str, to_date: str, interpret: bool = True,
|
||||||
|
|||||||
@@ -127,13 +127,20 @@ def interpret_run(
|
|||||||
lat: float = Form(0.0),
|
lat: float = Form(0.0),
|
||||||
lon: float = Form(0.0),
|
lon: float = Form(0.0),
|
||||||
group: bool = Form(False),
|
group: bool = Form(False),
|
||||||
|
action: str = Form("report"),
|
||||||
|
prompt_budget: str = Form("medium"),
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
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}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
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,
|
||||||
|
)
|
||||||
|
else:
|
||||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
@@ -161,13 +168,21 @@ def timeline_run(
|
|||||||
lon: float = Form(0.0),
|
lon: float = Form(0.0),
|
||||||
from_date: str = Form(...),
|
from_date: str = Form(...),
|
||||||
to_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,
|
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}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
|
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(
|
ctx["result"] = logic.timeline(
|
||||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
from_date=from_date, to_date=to_date, interpret=True,
|
from_date=from_date, to_date=to_date, interpret=True,
|
||||||
|
|||||||
@@ -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://<ip> — 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -81,3 +81,10 @@ tr:last-child td { border-bottom: none; }
|
|||||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||||
.leaflet-control-attribution { font-size: .68rem; }
|
.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; }
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{# Wspólny blok: sterowanie budżetem + wynik generatora promptu (LOG-29/30).
|
||||||
|
Używany na ekranach Interpretacje i Kalendarz. #}
|
||||||
|
<div class="opts">
|
||||||
|
<label>Budżet promptu
|
||||||
|
<select name="prompt_budget">
|
||||||
|
{% set pb = form.prompt_budget or 'medium' %}
|
||||||
|
<option value="concise" {{ 'selected' if pb == 'concise' else '' }}>zwięzły (~4 tys. znaków)</option>
|
||||||
|
<option value="medium" {{ 'selected' if pb == 'medium' else '' }}>średni (~12 tys.)</option>
|
||||||
|
<option value="extensive" {{ 'selected' if pb == 'extensive' else '' }}>obszerny (~30 tys.)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if prompt_result %}
|
||||||
|
{% set st = prompt_result.stats %}
|
||||||
|
<div class="meta">
|
||||||
|
Prompt gotowy · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
|
||||||
|
budżet: {{ st.budget }} ·
|
||||||
|
wskazań: <strong>{{ st.included }}</strong>
|
||||||
|
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
|
||||||
|
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if st.omitted %}
|
||||||
|
<p class="muted small">
|
||||||
|
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
|
||||||
|
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.data_error %}
|
||||||
|
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
|
||||||
|
<span class="muted small">Wklej do ChatGPT lub Claude.</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="promptText" class="prompt" rows="18" readonly>{{ prompt_result.prompt }}</textarea>
|
||||||
|
{% endif %}
|
||||||
@@ -28,9 +28,11 @@
|
|||||||
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
||||||
</div>
|
</div>
|
||||||
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||||
|
{% include "_prompt_block.html" %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit">Szukaj interpretacji</button>
|
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||||
|
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||||
<span id="geoNote" class="muted small"></span>
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -86,4 +88,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
|
<script src="/static/copy.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -33,9 +33,11 @@
|
|||||||
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{% include "_prompt_block.html" %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit">Pokaż kalendarz</button>
|
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
||||||
|
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||||
<span id="geoNote" class="muted small"></span>
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -76,4 +78,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
|
<script src="/static/copy.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user