Ranking siły (LOG-21) + grupowanie identycznych opisów + geolokalizacja

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 01:16:47 +02:00
parent 243d02d55b
commit f6323cac10
12 changed files with 146 additions and 8 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ i nie w bazie.
## API ## API
- `POST /api/query``QueryRequest``QueryResponse` - `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/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) - `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
- `GET /health` (sprawdza też warstwę bazodanową) - `GET /health` (sprawdza też warstwę bazodanową)
+1 -1
View File
@@ -53,7 +53,7 @@ def find_aspects(
if dev <= allowed: if dev <= allowed:
out.append({ out.append({
"obj1": a["name"], "obj2": b["name"], "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 break # jedna para = jeden aspekt
return out return out
+2 -1
View File
@@ -83,6 +83,7 @@ class ReportRequest(BaseModel):
lat: float = 0.0 lat: float = 0.0
lon: float = 0.0 lon: float = 0.0
limit: int = 5000 limit: int = 5000
group: bool = False # grupowanie identycznych opisów
@app.post("/chart/report") @app.post("/chart/report")
@@ -99,7 +100,7 @@ def chart_report(req: ReportRequest) -> dict:
try: try:
report = build_report( report = build_report(
chart["positions"], DataClient(), 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: except httpx.HTTPError as e:
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"} return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
+32
View File
@@ -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 ~012. 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)
+28
View File
@@ -15,6 +15,7 @@ from typing import Any, Protocol
from app.abbreviations import expand from app.abbreviations import expand
from app.engine.formats import SIGN_ABBR, SIGNS from app.engine.formats import SIGN_ABBR, SIGNS
from app.scoring import score_facet
PLANET_ABBR = { PLANET_ABBR = {
"Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma", "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 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( def build_report(
positions: list[dict], positions: list[dict],
data: DataSource, data: DataSource,
aspects: list[dict] | None = None, aspects: list[dict] | None = None,
per_object_limit: int = 5000, per_object_limit: int = 5000,
group: bool = False,
) -> dict: ) -> dict:
"""positions: pozycje z build_chart (name, sign, direction, house). """positions: pozycje z build_chart (name, sign, direction, house).
@@ -150,9 +170,17 @@ def build_report(
facets.append({ facets.append({
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}", "type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}",
"token": f"{asp_tok} + {other_tok}", "token": f"{asp_tok} + {other_tok}",
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
"count": len(asp_samples), "samples": asp_samples, "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({ items.append({
"object": name, "object": name,
"sign": sign, "sign": sign,
+20
View File
@@ -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
@@ -61,6 +61,28 @@ def test_aspect_facet():
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"] 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(): def test_ordinal():
assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd" assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd"
assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th" assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th"
@@ -44,9 +44,11 @@ class LogicClient:
r.raise_for_status() r.raise_for_status()
return r.json() 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.""" """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: with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/chart/report", json=payload) r = client.post(f"{self.base_url}/chart/report", json=payload)
r.raise_for_status() r.raise_for_status()
+4 -2
View File
@@ -114,13 +114,15 @@ def interpret_run(
tz_offset: float = Form(0.0), tz_offset: float = Form(0.0),
lat: float = Form(0.0), lat: float = Form(0.0),
lon: 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} 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
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: except httpx.HTTPError as e:
ctx["error"] = _logic_error(e) ctx["error"] = _logic_error(e)
except ValueError as e: except ValueError as e:
@@ -61,4 +61,6 @@ tr:last-child td { border-bottom: none; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; } .samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.facet { margin: .4rem 0 .6rem 1rem; } .facet { margin: .4rem 0 .6rem 1rem; }
.facet-head { color: var(--ink); font-size: .9rem; } .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; } .samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
@@ -119,6 +119,13 @@
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); 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=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60); 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);
});
}
}); });
</script> </script>
{% endblock %} {% endblock %}
@@ -23,6 +23,9 @@
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}"> <input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label> </label>
</div> </div>
<div class="opts">
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
</div>
<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">Szukaj interpretacji</button>
@@ -51,8 +54,20 @@
{% for f in o.facets %} {% for f in o.facets %}
<div class="facet"> <div class="facet">
<div class="facet-head">{{ f.label }} — <strong>{{ f.count }}</strong> dopasowań <div class="facet-head">{{ f.label }} — <strong>{{ f.count }}</strong> dopasowań
<span class="badge" title="siła (LOG-21)">siła {{ f.score }}</span>
<span class="muted small">[{{ f.token }}]</span></div> <span class="muted small">[{{ f.token }}]</span></div>
{% if f.samples %} {% if f.groups %}
<table class="samples">
<tbody>
{% for g in f.groups %}
<tr>
<td>{{ g.effect }}{% if g.count > 1 %} <span class="badge">×{{ g.count }}</span>{% endif %}</td>
<td class="sig small">{{ g.significators | join('; ') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elif f.samples %}
<table class="samples"> <table class="samples">
<tbody> <tbody>
{% for s in f.samples %} {% for s in f.samples %}
@@ -75,6 +90,13 @@
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); 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=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60); 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);
});
}
}); });
</script> </script>
{% endblock %} {% endblock %}