From f6323cac107135ece007cb21db9af2348b9aabc9 Mon Sep 17 00:00:00 2001 From: migatu Date: Sun, 5 Jul 2026 01:16:47 +0200 Subject: [PATCH] =?UTF-8?q?Ranking=20si=C5=82y=20(LOG-21)=20+=20grupowanie?= =?UTF-8?q?=20identycznych=20opis=C3=B3w=20+=20geolokalizacja?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Punktacja siły (LOG-21): - scoring.py: siła fasety z sygnałów obliczalnych (typ fasety, rodzaj aspektu, ciasnota orbu). Konfigurowalne wagi. Hook na przyszłość: kolumny countas*/level* z SIGNIFICATORS KEY (obecnie puste). - aspekty niosą orb+allowed; fasety dostają "score"; ranking faset malejąco. Grupowanie: - opcja group: zwija próbki po opisie (ten sam efekt = jedna grupa z listą sygnifikatorów i licznikiem). Checkbox "grupuj identyczne opisy" w /interpret. Geolokalizacja (bajer): - "Tu i teraz" (widok Horoskop i Interpretacje) uzupełnia lat/lon z przeglądarki (navigator.geolocation; wymaga zgody, https/localhost). Zweryfikowano na realnym main_base.xlsx: ranking sensowny (ciasna opozycja z Saturn 9.59 > szeroka koniunkcja z Moon 6.25 > znak/dom 5.0); grupowanie zwija powtórzone opisy. 41 testów przechodzi. Co-Authored-By: Claude Opus 4.8 --- services/logic/README.md | 2 +- services/logic/app/engine/aspects.py | 2 +- services/logic/app/main.py | 3 +- services/logic/app/scoring.py | 32 +++++++++++++++++++ services/logic/app/significators.py | 28 ++++++++++++++++ services/logic/tests/test_scoring.py | 20 ++++++++++++ services/logic/tests/test_significators.py | 22 +++++++++++++ .../presentation/app/clients/logic_client.py | 6 ++-- services/presentation/app/main.py | 6 ++-- services/presentation/app/static/styles.css | 2 ++ .../presentation/app/templates/chart.html | 7 ++++ .../presentation/app/templates/interpret.html | 24 +++++++++++++- 12 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 services/logic/app/scoring.py create mode 100644 services/logic/tests/test_scoring.py diff --git a/services/logic/README.md b/services/logic/README.md index 9fa6a4b..45c252b 100644 --- a/services/logic/README.md +++ b/services/logic/README.md @@ -7,7 +7,7 @@ i nie w bazie. ## API - `POST /api/query` → `QueryRequest` → `QueryResponse` - `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne (LOG-06). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`. -- `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów i odsiewaniem duplikatów (duplikat = ten sam sygnifikator i opis) — zalążek LOG-15/16/18/19 +- `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów) - `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/engine/aspects.py b/services/logic/app/engine/aspects.py index eeeccc9..354a0d6 100644 --- a/services/logic/app/engine/aspects.py +++ b/services/logic/app/engine/aspects.py @@ -53,7 +53,7 @@ def find_aspects( if dev <= allowed: out.append({ "obj1": a["name"], "obj2": b["name"], - "aspect": asp, "orb": round(dev, 2), + "aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2), }) break # jedna para = jeden aspekt return out diff --git a/services/logic/app/main.py b/services/logic/app/main.py index bd9e625..208a0f6 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -83,6 +83,7 @@ class ReportRequest(BaseModel): lat: float = 0.0 lon: float = 0.0 limit: int = 5000 + group: bool = False # grupowanie identycznych opisów @app.post("/chart/report") @@ -99,7 +100,7 @@ def chart_report(req: ReportRequest) -> dict: try: report = build_report( chart["positions"], DataClient(), - aspects=chart.get("aspects"), per_object_limit=req.limit, + aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group, ) except httpx.HTTPError as e: return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"} diff --git a/services/logic/app/scoring.py b/services/logic/app/scoring.py new file mode 100644 index 0000000..938e8da --- /dev/null +++ b/services/logic/app/scoring.py @@ -0,0 +1,32 @@ +"""Punktacja siły trafień — do rankingowania faset (zalążek LOG-21). + +v1 liczy siłę z sygnałów OBLICZALNYCH: +- typ fasety (aspekt zwykle mocniejszy od znaku/domu), +- rodzaj aspektu (koniunkcja/opozycja mocniejsze od sekstyla), +- ciasnota orbu (im bliżej dokładności, tym mocniej). + +Wszystko konfigurowalne. HOOK NA PRZYSZŁOŚĆ: gdy w SIGNIFICATORS KEY zostaną +wypełnione kolumny `countas*`/`level*`, można je tu domieszać per sygnifikator. +""" +from __future__ import annotations + +# bazowe wagi faset (łatwe do strojenia) +FACET_BASE = {"sign": 5.0, "house": 5.0, "aspect": 6.0} + +# względna siła aspektów głównych +ASPECT_WEIGHT = { + "conjunction": 1.0, "opposition": 0.95, "square": 0.85, + "trine": 0.85, "sextile": 0.65, +} + + +def score_facet(facet: dict) -> float: + """Siła fasety w skali ~0–12. Deterministyczna, konfigurowalna.""" + base = FACET_BASE.get(facet.get("type"), 1.0) + if facet.get("type") == "aspect": + weight = ASPECT_WEIGHT.get(facet.get("aspect"), 0.7) + orb = facet.get("orb") + allowed = facet.get("allowed") or 10.0 + tight = max(0.0, 1.0 - orb / allowed) if orb is not None and allowed else 0.0 + return round(base * weight * (1.0 + tight), 2) + return round(base, 2) diff --git a/services/logic/app/significators.py b/services/logic/app/significators.py index ada1fc5..cdfe2cb 100644 --- a/services/logic/app/significators.py +++ b/services/logic/app/significators.py @@ -15,6 +15,7 @@ from typing import Any, Protocol from app.abbreviations import expand from app.engine.formats import SIGN_ABBR, SIGNS +from app.scoring import score_facet PLANET_ABBR = { "Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma", @@ -87,11 +88,30 @@ def _facet_samples(rows: list[dict], tokens: list[str]) -> list[dict]: return out +def _group_by_effect(samples: list[dict]) -> list[dict]: + """Grupuje próbki po opisie: ten sam efekt = jedna grupa z listą sygnifikatorów.""" + groups: dict[str, dict] = {} + order: list[str] = [] + for s in samples: + key = _norm(s["effect"]) + g = groups.get(key) + if g is None: + g = {"effect": s["effect"], "count": 0, "significators": []} + groups[key] = g + order.append(key) + g["count"] += 1 + g["significators"].append(s["expanded"]) + result = [groups[k] for k in order] + result.sort(key=lambda g: g["count"], reverse=True) + return result + + def build_report( positions: list[dict], data: DataSource, aspects: list[dict] | None = None, per_object_limit: int = 5000, + group: bool = False, ) -> dict: """positions: pozycje z build_chart (name, sign, direction, house). @@ -150,9 +170,17 @@ def build_report( facets.append({ "type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}", "token": f"{asp_tok} + {other_tok}", + "aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"), "count": len(asp_samples), "samples": asp_samples, }) + # punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset + for f in facets: + f["score"] = score_facet(f) + if group: + f["groups"] = _group_by_effect(f["samples"]) + facets.sort(key=lambda f: f["score"], reverse=True) + items.append({ "object": name, "sign": sign, diff --git a/services/logic/tests/test_scoring.py b/services/logic/tests/test_scoring.py new file mode 100644 index 0000000..a909053 --- /dev/null +++ b/services/logic/tests/test_scoring.py @@ -0,0 +1,20 @@ +"""Testy punktacji siły faset (LOG-21).""" +from app.scoring import score_facet + + +def test_sign_and_house_base(): + assert score_facet({"type": "sign"}) == 5.0 + assert score_facet({"type": "house"}) == 5.0 + + +def test_tighter_aspect_scores_higher(): + tight = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 0.0, "allowed": 10.0}) + loose = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 9.0, "allowed": 10.0}) + assert tight > loose + assert tight == 12.0 # 6.0 * 1.0 * (1 + 1) + + +def test_conjunction_beats_sextile_at_same_orb(): + conj = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 2.0, "allowed": 10.0}) + sext = score_facet({"type": "aspect", "aspect": "sextile", "orb": 2.0, "allowed": 10.0}) + assert conj > sext diff --git a/services/logic/tests/test_significators.py b/services/logic/tests/test_significators.py index bbf4fcb..7924005 100644 --- a/services/logic/tests/test_significators.py +++ b/services/logic/tests/test_significators.py @@ -61,6 +61,28 @@ def test_aspect_facet(): assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"] +def test_grouping_by_effect(): + positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}] + data = FakeData({"[Ma": [ + {"significator": "[Ma in 5th H.", "actioneffect": "miscarriage"}, + {"significator": "[Sa in 5th H.", "actioneffect": "miscarriage"}, # inny sig, ten sam opis + {"significator": "[Ma in 5th H.", "actioneffect": "fever"}, + ]}) + house = [f for f in build_report(positions, data, group=True)["objects"][0]["facets"] + if f["type"] == "house"][0] + groups = {g["effect"]: g for g in house["groups"]} + assert groups["miscarriage"]["count"] == 2 + assert len(groups["miscarriage"]["significators"]) == 2 + assert "score" in house + + +def test_facets_carry_score(): + positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}] + data = FakeData({"[Su": [{"significator": "[Su in [Tau", "actioneffect": "e"}]}) + facets = build_report(positions, data)["objects"][0]["facets"] + assert all("score" in f for f in facets) + + def test_ordinal(): assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd" assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th" diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index 3aa9364..bc8c0f0 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -44,9 +44,11 @@ class LogicClient: r.raise_for_status() return r.json() - def report(self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000) -> dict[str, Any]: + def report( + self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False + ) -> 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} + payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group} 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() diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 6b9ad69..1b5a91c 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -114,13 +114,15 @@ def interpret_run( tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0), + group: bool = Form(False), ): - 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, "group": group} 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) + 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: diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index d25842c..277c0f5 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -61,4 +61,6 @@ tr:last-child td { border-bottom: none; } .samples td.nowrap { color: var(--accent); padding-right: 1rem; } .facet { margin: .4rem 0 .6rem 1rem; } .facet-head { color: var(--ink); font-size: .9rem; } +.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px; + background: #2a2d4a; color: var(--accent); margin-left: .3rem; } .samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; } diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index 953a41d..01a49d9 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -119,6 +119,13 @@ d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes()); document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60); + // bajer: lokalizacja z przeglądarki (wymaga zgody; działa na https/localhost) + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(function (pos) { + document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4); + document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4); + }); + } }); {% endblock %} diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html index a1ca720..6fa24e9 100644 --- a/services/presentation/app/templates/interpret.html +++ b/services/presentation/app/templates/interpret.html @@ -23,6 +23,9 @@ +
+ +
@@ -51,8 +54,20 @@ {% for f in o.facets %}
{{ f.label }} — {{ f.count }} dopasowań + siła {{ f.score }} [{{ f.token }}]
- {% if f.samples %} + {% if f.groups %} + + + {% for g in f.groups %} + + + + + {% endfor %} + +
{{ g.effect }}{% if g.count > 1 %} ×{{ g.count }}{% endif %}{{ g.significators | join('; ') }}
+ {% elif f.samples %} {% for s in f.samples %} @@ -75,6 +90,13 @@ d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes()); document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60); + // bajer: lokalizacja z przeglądarki (wymaga zgody; działa na https/localhost) + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(function (pos) { + document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4); + document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4); + }); + } }); {% endblock %}