diff --git a/services/data/app/models.py b/services/data/app/models.py index 781cb8b..3477c1f 100644 --- a/services/data/app/models.py +++ b/services/data/app/models.py @@ -18,7 +18,7 @@ class SearchQuery(BaseModel): key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.") value: str = Field(..., description="Szukana wartość.") exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).") - limit: int = Field(50, ge=1, le=1000) + limit: int = Field(50, ge=1, le=50000) fields: list[str] | None = Field( None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie." ) diff --git a/services/data/app/providers/excel_provider.py b/services/data/app/providers/excel_provider.py index d564010..93c4e85 100644 --- a/services/data/app/providers/excel_provider.py +++ b/services/data/app/providers/excel_provider.py @@ -115,7 +115,9 @@ class ExcelDataProvider(DataProvider): if query.exact: mask = col.str.lower() == query.value.lower() else: - mask = col.str.lower().str.contains(query.value.lower(), na=False) + # regex=False: wartości sygnifikatorów zawierają znaki [ + itd., + # które są metaznakami regex — szukamy dosłownie. + mask = col.str.lower().str.contains(query.value.lower(), na=False, regex=False) matched = frame[mask] if query.fields: keep = [c for c in query.fields if c in matched.columns] diff --git a/services/logic/README.md b/services/logic/README.md index e5ccf82..5cad6da 100644 --- a/services/logic/README.md +++ b/services/logic/README.md @@ -6,6 +6,8 @@ i nie w bazie. ## API - `POST /api/query` → `QueryRequest` → `QueryResponse` +- `POST /chart/positions` → `{when_utc, lat, lon, objects?}` → pozycje obiektów (LOG-01) +- `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: z pozycji generuje sygnifikatory (planeta w znaku) i zwraca pasujące interpretacje z warstwy danych (zalążek LOG-16/18/19) - `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`. - `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B) - `GET /health` (sprawdza też warstwę bazodanową) diff --git a/services/logic/app/clients/data_client.py b/services/logic/app/clients/data_client.py index ecbd686..5fd9dc3 100644 --- a/services/logic/app/clients/data_client.py +++ b/services/logic/app/clients/data_client.py @@ -16,9 +16,16 @@ class DataClient: def __init__(self, base_url: str | None = None) -> None: self.base_url = (base_url or settings.data_url).rstrip("/") - def search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]: - payload = {"key": key, "value": value, "exact": exact, "limit": limit} - with httpx.Client(timeout=settings.http_timeout) as client: + def search( + self, + key: str, + value: str, + exact: bool, + limit: int, + fields: list[str] | None = None, + ) -> dict[str, Any]: + payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields} + with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client: r = client.post(f"{self.base_url}/search", json=payload) r.raise_for_status() return r.json() diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 3904444..aa46937 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -78,6 +78,30 @@ def chart_compare(req: PositionsRequest) -> dict: return report.summary() +class ReportRequest(BaseModel): + when_utc: datetime + lat: float = 0.0 + lon: float = 0.0 + limit: int = 5000 + + +@app.post("/chart/report") +def chart_report(req: ReportRequest) -> dict: + """Wynik obliczeń szukany w bazie: z pozycji generuje sygnifikatory i pyta + warstwę danych o pasujące interpretacje (pierwsza wersja przepływu).""" + from app.engine.models import ChartMoment + from app.significators import build_report + + engine = get_engine() + moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) + positions = engine.positions(moment) + try: + report = build_report(positions, DataClient(), per_object_limit=req.limit) + except httpx.HTTPError as e: + return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"} + return {"engine": engine.name, **report} + + @app.get("/health") def health() -> dict: info = {"status": "ok", "layer": "logic"} diff --git a/services/logic/app/significators.py b/services/logic/app/significators.py new file mode 100644 index 0000000..8fe607a --- /dev/null +++ b/services/logic/app/significators.py @@ -0,0 +1,88 @@ +"""Most: policzony horoskop → tokeny sygnifikatorów → wyszukiwanie w bazie. + +Pierwsza wersja (LOG-16/18/19 w zalążku): z pozycji obiektów generujemy tokeny w +składni bazy (np. Słońce w Byku → planeta `[Su`, znak `[Tau`), pytamy warstwę +danych o rekordy zawierające token planety, a następnie zawężamy do tych, które +wspominają też jej znak („planeta w swoim znaku"). To realizuje przepływ „wynik +obliczeń szukany w pliku". + +Format skrótów odczytany z realnej bazy (Encyclopaedia of Medical Astrology): +planety `[Su`,`[Mo`,…; znaki `[Ari`,`[Tau`,…; np. `[Su affl. in [Vir`. +""" +from __future__ import annotations + +from typing import Any, Protocol + +from app.engine.formats import SIGN_ABBR, sign_index + +PLANET_ABBR = { + "Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma", + "Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl", +} + + +class DataSource(Protocol): + def search( + self, key: str, value: str, exact: bool, limit: int, fields: list[str] | None = None + ) -> dict[str, Any]: ... + + +def _effect(row: dict) -> str: + for col in ("actioneffect", "topicresult", "bodypart"): + v = row.get(col) + if v and str(v).strip().lower() not in ("", "nan"): + return str(v).strip() + return "" + + +def _is_noise(sig: str, effect: str) -> bool: + s = sig.strip().lower() + return ( + not effect + or s.startswith("significator") + or "header" in s + or s in ("x", "x?", "nan") + ) + + +def build_report(positions, data: DataSource, per_object_limit: int = 60) -> dict: + """Dla każdego obiektu: wyszukaj sygnifikatory planety i zawęź do jej znaku.""" + items: list[dict] = [] + provider = None + for p in positions: + if p.name not in PLANET_ABBR: + continue + planet_tok = "[" + PLANET_ABBR[p.name] + sign_tok = "[" + SIGN_ABBR[sign_index(p.longitude)] + + raw = data.search( + key="significator", + value=planet_tok, + exact=False, + limit=per_object_limit, + fields=["significator", "actioneffect", "topicresult", "bodypart"], + ) + provider = raw.get("provider", provider) + rows = raw.get("rows", []) + + samples: list[dict] = [] + for r in rows: + sig = str(r.get("significator") or "") + if sign_tok.lower() not in sig.lower(): + continue + eff = _effect(r) + if _is_noise(sig, eff): + continue + samples.append({"significator": sig.strip(), "effect": eff}) + + items.append({ + "object": p.name, + "sign": p.sign, + "direction": p.direction, + "planet_token": planet_tok, + "sign_token": sign_tok, + "planet_total": raw.get("total", 0), + "in_sign_count": len(samples), + "samples": samples[:5], + }) + return {"provider": provider, "objects": items} diff --git a/services/logic/tests/test_significators.py b/services/logic/tests/test_significators.py new file mode 100644 index 0000000..a71c6dd --- /dev/null +++ b/services/logic/tests/test_significators.py @@ -0,0 +1,44 @@ +"""Testy mostu obliczenia → sygnifikatory → wyszukiwanie (bez efemeryd/HTTP).""" +from app.engine.models import DEFAULT_OBJECTS, ObjectPosition +from app.significators import PLANET_ABBR, build_report + + +class FakeData: + def __init__(self, rows_by_value: dict) -> None: + self.rows_by_value = rows_by_value + + def search(self, key, value, exact, limit, fields=None) -> dict: + rows = self.rows_by_value.get(value, []) + return {"provider": "fake", "total": len(rows), "rows": rows} + + +def test_build_report_filters_to_sign_and_drops_noise(): + positions = [ObjectPosition("Sun", 40.0, 0.0, 0.95, False)] # Taurus 10° -> [Su + [Tau + data = FakeData({"[Su": [ + {"significator": "[Su in [Tau", "actioneffect": "efekt A"}, # trafienie + {"significator": "[Su in [Vir", "actioneffect": "inny znak"}, # zły znak + {"significator": "[Su in [Tau", "actioneffect": "nan"}, # szum (pusty efekt) + {"significator": "SIGNIFICATOR nagłówek", "actioneffect": "x"}, # szum (nagłówek) + ]}) + item = build_report(positions, data, per_object_limit=100)["objects"][0] + assert item["object"] == "Sun" and item["sign"] == "Taurus" + assert item["planet_token"] == "[Su" and item["sign_token"] == "[Tau" + assert item["planet_total"] == 4 + assert item["in_sign_count"] == 1 + assert item["samples"][0]["effect"] == "efekt A" + + +def test_effect_falls_back_to_topic_or_bodypart(): + positions = [ObjectPosition("Mars", 220.0, 0.0, -0.2, True)] # Scorpio -> [Ma + [Sco + data = FakeData({"[Ma": [ + {"significator": "[Ma in [Sco", "actioneffect": "", "topicresult": "temat X"}, + {"significator": "[Ma in [Sco", "bodypart": "część ciała"}, + ]}) + samples = build_report(positions, data)["objects"][0]["samples"] + assert samples[0]["effect"] == "temat X" + assert samples[1]["effect"] == "część ciała" + + +def test_planet_abbr_covers_all_default_objects(): + for o in DEFAULT_OBJECTS: + assert o in PLANET_ABBR diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index 67b503f..3aa9364 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -43,3 +43,11 @@ class LogicClient: r = client.post(f"{self.base_url}/chart/positions", json=payload) r.raise_for_status() return r.json() + + def report(self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000) -> dict[str, Any]: + """Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report.""" + payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit} + with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client: + r = client.post(f"{self.base_url}/chart/report", json=payload) + r.raise_for_status() + return r.json() diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 7496554..6b9ad69 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -100,6 +100,34 @@ def significators_search( return templates.TemplateResponse(request, "significators.html", ctx) +# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ---------------- +@app.get("/interpret", response_class=HTMLResponse) +def interpret_form(request: Request): + return templates.TemplateResponse(request, "interpret.html", {"result": None, "form": {}}) + + +@app.post("/interpret", response_class=HTMLResponse) +def interpret_run( + request: Request, + date: str = Form(...), + time: str = Form(...), + tz_offset: float = Form(0.0), + lat: float = Form(0.0), + lon: float = Form(0.0), +): + form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon} + 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) + except httpx.HTTPError as e: + ctx["error"] = _logic_error(e) + except ValueError as e: + ctx["error"] = f"Niepoprawne dane wejściowe: {e}" + return templates.TemplateResponse(request, "interpret.html", ctx) + + @app.get("/health") def health() -> dict: return {"status": "ok", "layer": "presentation"} diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index a632745..0943574 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -50,3 +50,12 @@ table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: v th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); } th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; } tr:last-child td { border-bottom: none; } + +.muted { color: var(--muted); } +.small { font-size: .85rem; } +.nowrap { white-space: nowrap; } +.sig-item { margin-top: 1.25rem; } +.sig-head { padding: .5rem 0; } +.samples { margin-top: .3rem; } +.samples td { font-size: .92rem; vertical-align: top; } +.samples td.nowrap { color: var(--accent); padding-right: 1rem; } diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html index 7142264..d2791e8 100644 --- a/services/presentation/app/templates/base.html +++ b/services/presentation/app/templates/base.html @@ -12,6 +12,7 @@

astrololo

diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html new file mode 100644 index 0000000..aa5f4f4 --- /dev/null +++ b/services/presentation/app/templates/interpret.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}Interpretacje{% endblock %} +{% block nav_interp %}active{% endblock %} + +{% block content %} +

Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).

+ +
+
+ + + + + +
+
+ + +
+
+ +{% if error %}
{{ error }}
{% endif %} + +{% if result %} + {% if result.data_error %} +
{{ result.data_error }}
+ {% endif %} +
+ Silnik: {{ result.engine }} + {% if result.provider %}· baza: {{ result.provider }}{% endif %} + {% if moment %}· moment: {{ moment }}{% endif %} +
+ + {% for o in result.objects %} +
+
+ {{ o.object }} w {{ o.sign }} + {{ o.direction }} + — szukano {{ o.planet_token }} + {{ o.sign_token }}; + w znaku: {{ o.in_sign_count }} (planeta ogółem: {{ o.planet_total }}) +
+ {% if o.samples %} + + + {% for s in o.samples %} + + {% endfor %} + +
{{ s.significator }}{{ s.effect }}
+ {% else %} +
brak dopasowań „w znaku" w bazie
+ {% endif %} +
+ {% endfor %} +{% endif %} + + +{% endblock %}