mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 21:38:37 +00:00
Merge pull request #14 from migatu/feat/scoring-grouping-geo
Ranking sily (LOG-21) + grupowanie identycznych opisow + geolokalizacja
This commit is contained in:
@@ -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ą)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user