Compare commits

..

4 Commits

Author SHA1 Message Date
gitea 8d579de34a Aspekty: applying/separating (A/S) + bonus siły dla aplikujących (LOG-06)
- aspects.py: _is_applying — odchyłka od dokładnego kąta teraz vs po małym
  kroku czasu (prędkość·dt, dt=0,01 doby by szybki Księżyc nie przeskoczył
  dokładności); aspekt niesie applying (bool) i "as": A/S. Bez prędkości —
  brak flagi (None).
- scoring: aspekt aplikacyjny silniejszy (APPLYING_BONUS 1.15) — zgodnie z
  notatkami projektu ("impact considered more powerful").
- significators: faseta aspektu niesie applying, etykieta z sufiksem (A)/(S).
- widok Horoskop: kolumna A/S w tabeli aspektów (tooltip z objaśnieniem).

Walidacja: flagi A/S wszystkich 16 aspektów horoskopu referencyjnego
(30.04.1984, Warszawa) zgodne z tabelą astro-seek z notes3 (test regresyjny
REFERENCE_AS). 45 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:22:51 +02:00
gitea db3d1e5117 Merge pull request #15 from migatu/fix/geo-secure-context
Geolokalizacja: jawny komunikat gdy brak secure context (http)
2026-07-07 19:01:19 +02:00
gitea 4a86d34f5a Geolokalizacja: jawny komunikat gdy brak secure context (http://)
Przyczyna "nie pyta o zgodę": navigator.geolocation działa tylko w secure
context (https:// lub localhost). Na http://<ip> przeglądarka po cichu
odmawia — bez promptu; kod nie miał callbacku błędu, więc nic nie było widać.

- wspólny static/now.js (deduplikacja skryptu z chart.html i interpret.html)
- jawna detekcja window.isSecureContext + czytelny komunikat w #geoNote
  ("wymaga HTTPS lub localhost — wpisz lat/lon ręcznie")
- callback błędu (odmowa/timeout) też widoczny; status "Pobieram lokalizację…"
  i potwierdzenie po sukcesie

Zweryfikowano: /static/now.js serwowany (200), obie strony referencjonują
skrypt i mają #geoNote.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:59:03 +02:00
gitea fb317172ff Merge pull request #14 from migatu/feat/scoring-grouping-geo
Ranking sily (LOG-21) + grupowanie identycznych opisow + geolokalizacja
2026-07-06 19:29:56 +02:00
8 changed files with 143 additions and 43 deletions
+1 -1
View File
@@ -6,7 +6,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 z applying/separating (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, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów) - `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ą)
+29 -3
View File
@@ -34,10 +34,29 @@ def separation(a: float, b: float) -> float:
return min(d, 360.0 - d) return min(d, 360.0 - d)
def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None:
"""Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)?
Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu
(pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying.
dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności.
Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć).
"""
if sa is None or sb is None:
return None
dev_now = abs(separation(la, lb) - angle)
dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle)
return dev_next < dev_now
def find_aspects( def find_aspects(
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]: ) -> list[dict]:
"""positions: dicty z 'name' i 'decimal' (długość). Zwraca listę aspektów.""" """positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
"""
out: list[dict] = [] out: list[dict] = []
n = len(positions) n = len(positions)
for i in range(n): for i in range(n):
@@ -51,9 +70,16 @@ def find_aspects(
for asp, angle in MAJOR.items(): for asp, angle in MAJOR.items():
dev = abs(sep - angle) dev = abs(sep - angle)
if dev <= allowed: if dev <= allowed:
out.append({ applying = _is_applying(
float(la), float(lb), a.get("speed"), b.get("speed"), angle
)
row = {
"obj1": a["name"], "obj2": b["name"], "obj1": a["name"], "obj2": b["name"],
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2), "aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
}) }
if applying is not None:
row["applying"] = applying
row["as"] = "A" if applying else "S"
out.append(row)
break # jedna para = jeden aspekt break # jedna para = jeden aspekt
return out return out
+6 -1
View File
@@ -18,6 +18,8 @@ ASPECT_WEIGHT = {
"conjunction": 1.0, "opposition": 0.95, "square": 0.85, "conjunction": 1.0, "opposition": 0.95, "square": 0.85,
"trine": 0.85, "sextile": 0.65, "trine": 0.85, "sextile": 0.65,
} }
# aspekt aplikacyjny (A) jest silniejszy niż separacyjny (S) — notes3
APPLYING_BONUS = 1.15
def score_facet(facet: dict) -> float: def score_facet(facet: dict) -> float:
@@ -28,5 +30,8 @@ def score_facet(facet: dict) -> float:
orb = facet.get("orb") orb = facet.get("orb")
allowed = facet.get("allowed") or 10.0 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 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) score = base * weight * (1.0 + tight)
if facet.get("applying"):
score *= APPLYING_BONUS
return round(score, 2)
return round(base, 2) return round(base, 2)
+3 -1
View File
@@ -167,10 +167,12 @@ def build_report(
asp_samples = _facet_samples(rows, [asp_tok, other_tok]) asp_samples = _facet_samples(rows, [asp_tok, other_tok])
if not asp_samples: # pokazujemy tylko aspekty z trafieniami if not asp_samples: # pokazujemy tylko aspekty z trafieniami
continue continue
as_suffix = f" ({asp['as']})" if asp.get("as") else ""
facets.append({ facets.append({
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}", "type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}{as_suffix}",
"token": f"{asp_tok} + {other_tok}", "token": f"{asp_tok} + {other_tok}",
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"), "aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
"applying": asp.get("applying"),
"count": len(asp_samples), "samples": asp_samples, "count": len(asp_samples), "samples": asp_samples,
}) })
+55
View File
@@ -33,3 +33,58 @@ def test_luminary_bonus_widens_orb():
def test_one_aspect_per_pair(): def test_one_aspect_per_pair():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}] pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert len(find_aspects(pos)) == 1 assert len(find_aspects(pos)) == 1
def test_applying_when_faster_body_catches_up():
# Księżyc 5° za Słońcem, szybszy -> koniunkcja aplikacyjna
pos = [
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
{"name": "Moon", "decimal": 35.0, "speed": 13.0},
]
a = find_aspects(pos)[0]
assert a["applying"] is True and a["as"] == "A"
def test_separating_when_moving_apart():
# Księżyc 5° przed Słońcem i szybszy -> koniunkcja separacyjna
pos = [
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
{"name": "Moon", "decimal": 45.0, "speed": 13.0},
]
a = find_aspects(pos)[0]
assert a["applying"] is False and a["as"] == "S"
def test_no_as_flag_without_speeds():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert "as" not in find_aspects(pos)[0]
# Referencja A/S z notes3 (astro-seek) dla horoskopu 30.04.1984 07:35 UT, Warszawa.
REFERENCE_AS = {
("Sun", "Moon", "conjunction"): "A",
("Sun", "Jupiter", "trine"): "A",
("Sun", "Saturn", "opposition"): "A",
("Sun", "Neptune", "trine"): "S",
("Sun", "Pluto", "opposition"): "S",
("Moon", "Mercury", "conjunction"): "S",
("Moon", "Venus", "conjunction"): "S",
("Moon", "Neptune", "trine"): "A",
("Moon", "Pluto", "opposition"): "S",
("Mercury", "Venus", "conjunction"): "S",
("Mercury", "Neptune", "trine"): "S",
("Mercury", "Pluto", "opposition"): "S",
("Venus", "Neptune", "trine"): "A",
("Venus", "Pluto", "opposition"): "A",
("Jupiter", "Saturn", "sextile"): "A",
("Neptune", "Pluto", "sextile"): "S",
}
def test_applying_separating_matches_astroseek_reference(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
got = {(a["obj1"], a["obj2"], a["aspect"]): a.get("as") for a in chart["aspects"]}
mismatches = {k: (got.get(k), v) for k, v in REFERENCE_AS.items() if got.get(k) != v}
assert not mismatches, f"rozbieżności A/S vs astro-seek: {mismatches}"
+43
View File
@@ -0,0 +1,43 @@
// „Tu i teraz": uzupełnia datę/godzinę/strefę z przeglądarki oraz — jeśli to
// możliwe — lokalizację (lat/lon).
//
// UWAGA: geolokalizacja przeglądarki działa tylko w "secure context"
// (https:// lub localhost). Na http://<ip> przeglądarka NIE pyta o zgodę,
// tylko po cichu odmawia — dlatego pokazujemy jawny komunikat w #geoNote.
document.addEventListener('DOMContentLoaded', function () {
const btn = document.getElementById('nowBtn');
if (!btn) return;
const note = document.getElementById('geoNote');
const say = msg => { if (note) note.textContent = msg; };
btn.addEventListener('click', function () {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
document.querySelector('input[name=date]').value =
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);
if (!('geolocation' in navigator)) {
say('Ta przeglądarka nie udostępnia geolokalizacji — wpisz lat/lon ręcznie.');
return;
}
if (!window.isSecureContext) {
say('Lokalizacja z przeglądarki wymaga HTTPS lub localhost (otwarto przez http://) — wpisz lat/lon ręcznie.');
return;
}
say('Pobieram lokalizację…');
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);
say('Lokalizacja pobrana ✓');
},
function (err) {
say('Nie udało się pobrać lokalizacji: ' + (err && err.message ? err.message : 'odmowa dostępu'));
},
{ timeout: 8000 }
);
});
});
+4 -20
View File
@@ -36,6 +36,7 @@
<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">Policz horoskop</button> <button type="submit">Policz horoskop</button>
<span id="geoNote" class="muted small"></span>
</div> </div>
</form> </form>
@@ -86,10 +87,10 @@
{% if result.aspects %} {% if result.aspects %}
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div> <div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
<table class="angles"> <table class="angles">
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th></tr></thead> <thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody> <tbody>
{% for a in result.aspects %} {% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td></tr> <tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -110,22 +111,5 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<script> <script src="/static/now.js"></script>
// „Tu i teraz": uzupełnia datę/godzinę bieżącą i offset lokalny przeglądarki.
document.getElementById('nowBtn').addEventListener('click', function () {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
document.querySelector('input[name=date]').value =
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);
});
}
});
</script>
{% endblock %} {% endblock %}
@@ -29,6 +29,7 @@
<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>
<span id="geoNote" class="muted small"></span>
</div> </div>
</form> </form>
@@ -82,21 +83,5 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<script> <script src="/static/now.js"></script>
document.getElementById('nowBtn').addEventListener('click', function () {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
document.querySelector('input[name=date]').value =
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);
});
}
});
</script>
{% endblock %} {% endblock %}