Initial: Mesh-Skills, Configs, Scripts

This commit is contained in:
root
2026-09-02 23:49:07 +02:00
commit 26c7980750
783 changed files with 145393 additions and 0 deletions
@@ -0,0 +1,215 @@
# Flask + SQLite: Mehrstufige "Drill-Down"-Website mit nginx-Reverse-Proxy
Bewährt für datengetriebene Sites, die über mehrere Ebenen verschachtelt navigierbar
sind, z.B. `urlaub.perfekt-ohne-en.de`: Ebene1 (Start Radevormwald → 33 Ziele ≥100k),
Klick auf ein Ziel → Ebene2 (Liste ab dieser Stadt, ≥50k), Klick → Ebene3.
## Warum Backend (Flask+SQLite) statt statischer HTML
Für 3 Ebenen wären statische Seiten **~1000–2000 Dateien** (jede Ebene-2-Zielstadt
wieder eine Liste + PDFs) — unwartbar bei häufigen Datenänderungen. Ein Backend mit
DB: eine `UPDATE` → alle Seiten/Pfade sofort korrekt. SQLite reicht locker
(<100 Queries/Tag, persönliches Projekt). Team-Empfehlung (UnraidHermes) bestätigt.
## Architektur
```
nginx (Subdomain vhost) → proxy_pass 127.0.0.1:8040 → Flask (app.py)
└─ SQLite (urlaub.db)
```
- Flask läuft als **systemd-Service** (`/opt/<app>/venv/bin/python app.py`).
- nginx-`location / { proxy_pass http://127.0.0.1:8040; proxy_set_header Host $host; ... }`
- PDF on-demand: Flask-Route rendert Template → `wkhtmltopdf` → Response(pdf).
## Kern-Implementation (app.py)
- SQLite-Verbindung je Request (`sqlite3.connect`, `row_factory = sqlite3.Row`).
- Slugs für URLs: `frankfurt-am-main`, `osnabrueck`, `goettingen`.
- **Slug↔Stadtname-Match muss Fallback/Transkription akzeptieren** — URL ist
lowercase slug mit Bindestrichen, DB speichert echten Namen ("Frankfurt am Main",
"Osnabrück"). Match mit `LOWER(replace(nach,' ',''))=LOWER(replace(?,' ',''))`
UND Umlaut-Rücktranskription: `ue→ü/ue/u`, `oe→ö/oe/o`, `ae→ä/ae/a`, `ss→ß/ss/s`.
- `slug()` muss Umlaute VOR `ascii-encode` expandieren: `ä→ae, ö→oe, ü→ue, ß→ss`.
- Ebene-Route: `@app.route('/<int:ebene>/<slug_name>')` — `ebene in (2,3)`, sonst 404.
- Startstadt = die angematchte Stadt; Ziele = `WHERE ebene=? AND von=?`.
- Leere Ebene → eigenes `leer.html`-Template ("Noch keine Ziele…"), kein 404.
- `/healthz` → `"ok"` für systemd/nginx-Readiness.
## Deployment-Ablauf
1. `python3 -m venv venv && venv/bin/pip install flask` (sqlite3 in stdlib).
2. `venv/bin/python -c "import flask"` (Syntax-/Install-Check NICHT mit `||`-Fehler
in derselben Python-Zeile verwechseln).
3. DB-Init + Seed (Skripte `db_init.py`, `seed_ebene2.py`).
4. Testen: `./venv/bin/python app.py` im Hintergrund → `curl 127.0.0.1:8040/healthz`
und Routen. Dann killen, Start an systemd übergeben:
`ExecStart=/opt/<app>/venv/bin/python /opt/<app>/app.py`, `Restart=always`.
5. nginx: Proxy gegen `127.0.0.1:8040` einrichten (siehe Haupt-SKILL: vhost via
`/tmp` + `cp` — write_file weigert sich bei `/etc/nginx/*`; Backup-Regel vorher).
6. `nginx -t && systemctl reload nginx`, dann über `https://<sub>` verifizieren.
## ⚠️ Änderungen an Templates/Code erfordern einen Service-Neustart (2026-08-08)
Flask läuft hier mit `debug=False` (systemd). Das heißt: Template-Edits (`*.html`)
und `app.py`-Änderungen werden **NICHT automatisch neu geladen**. Symptom aus dem
Live-Betrieb: Nachdem eine neue Kategorie-Gruppe (z.B. `unter 5h`) ins Template
kam, zeigte die laufende Seite sie **nicht** — erst `systemctl restart urlaub.service`
brachte sie sichtbar. **Regel: nach jeder Template/Code-Änderung IMMER den systemd-
Service neu starten** und dann die Seite per `curl` gegen die erwartete neue Markierung
prüfen (z.B. `grep -oE '<h2>[^<]+</h2>'`). Nur DB-Seed-/Update-Skripte (die `urlaub.db`
schreiben) wirken sofort, weil jede Request frisch auf SQLite liest.
## Verifikation (immer durchführen)
```bash
curl -sk -o /dev/null -w "/ → %{http_code}\n" https://<sub>/
curl -sk -o /dev/null -w "/2/<stadt> → %{http_code}\n" https://<sub>/2/<stadt>
curl -sk -o /dev/null -w "/3/<stadt> → %{http_code}\n" https://<sub>/3/<stadt>
curl -sk -o /dev/null -w "/pdf/<stadt> → %{http_code}\n" https://<sub>/pdf/<stadt>
```
HTTP 000 + curl-Exit 60 = nur Zert-Warnung, `-k` zeigt 200.
## Datenquellen für Ebene-2/3 am sinnvollsten
- **Start mit kleinen, konkreten Team-Tasks** ("gib je Startstadt Ziele ≥50k im
Format Stadt|~h|Ziel1,Ziel2") — NICHT breit formulieren; Sub-Agenten liefern sonst
nur Enzyklopädie/Regionen statt präziser Stadt-zu-Stadt-Zuordnung (wiederholtes Muster).
- Der Master kuratiert die Team-Rohdaten gegen Geographie (keine offensichtlichen
Fehler), markiert alles als **Richtwerte**.
- Ebene-3-Daten optional; Route existiert und zeigt sauber `leer.html` bis befüllt.
## ⚠️ Datenqualität: NIE Platzhalter-Zahlen als echtes Datum (2026-08-08)
Beim Seeden von Ebene-2-Städten wurde `einwohner=150000` pauschal für ALLE gesetzt.
Der Nutzer bemerkte sofort "es gibt keine 50k–100k-Städte in der Liste" — korrekt:
alle kleineren Städte (Würzburg, Koblenz, Hameln …) zeigten fälschlich 150k.
**Regeln:**
- Jeder Eintrag braucht **echte, individuell korrekte** Werte. Unbekannte als
`NULL`/"k.A." führen — **niemals** einen plausiblen Platzhalter wie `150000`.
- Nach dem Seed IMMER einen Duplikat-/Platzhalter-Check laufen lassen:
`SELECT nach FROM verkehr WHERE einwohner=<platzhalter>` → darf 0 Treffer liefern.
- Wenn echte Zahlen fehlen (kein Webzugriff), schätzen mit **Bereich + Label**
("Richtwert"), nicht als exakte Zahl. Beim späteren Update echte Werte nachtragen.
- 50k–100k-Ziele sind der eigentliche Kern einer kleinere-Schwelle-Liste — sie
explizit ergänzen (Team lieferte konkrete: ab Trier→Worms/Bad Kreuznach/
Idar-Oberstein/Neunkirchen; ab Hannover→Hameln/Celle/Goslar/Lüneburg/Delmenhorst).
## ⚠️ Finale Ebene darf keine Dangling-Links auf nicht-existente Unter-Ebene erzeugen (2026-08-08)
Das Drill-Down-Template verlinkte jedes Ziel auf `/<ebene+1>/<slug>` — auch auf der
**letzten** Ebene. Bei 3 Ebenen zeigte Ebene-3 daher Ziele, die auf **`/4/<stadt>`**
verwiesen, eine Route die es nicht gibt → **404** (und Verstoß gegen die Nutzer-Erwartung
„jede Ebene hat eine Unter-Ebene… bis die es gibt«). User bemerkte es sofort.
**Fix / Regel:** Das Template muss die **letzte Ebene als final markieren** statt
einen toten Weiter-Link zu rendern:
```jinja
{% if ebene <= MAX_EBENE_DEPTH %}
<td><a href="/{{ ebene+1 }}/{{ z.nach|lower }}">{{ z.nach }}</a></td>
{% else %}
<td>{{ z.nach }} <small>(final)</small></td>
{% endif %}
```
Generell: **Jeder `href` auf eine `<ebene+1>`-Route darf nur in Ebenen gerendert
werden, die wirklich existieren** (≤ Tiefe). Vor dem Livegehen alle Funktions­pfade
durchklicken (curl auf `/1/ `/2/ `/3/`) und darauf achten, DASS kein Link auf eine
NICHT eingerichtete Ebene zeigt (`grep -oE 'href="/4/'` darf leer sein).
## Umlaut-Slug-Match: auch umgekehrte Einzelbuchstaben berücksichtigen (2026-08-08)
Der Slug für `Köln` ist `koln` (nur `ö→o` beim Sluggen), während ein Nutzer/anderer
Kandidat `koeln` (Transkription) verwenden könnte und die DB `Köln` speichert. Der
Stadtname-Match muss ALSO **beide^ Richtungen** anbieten:
- Transkription: `ue→ü, oe→ö, ae→ä, ss→ß` (für Slugs, die voll transkribieren).
- **Einzelbuchstaben umgekehrt:** `o→ö, u→ü, a→ä, s→ß` (für URL-Slugs wie `koln`,
`wuerzburg`→`würzburg`), damit `koln`→`Köln` matcht.
Jeder Fund-Kandidat wird gegen `LOWER(replace(nach,' ',''))` geprüft; der erste
Treffer gewinnt. Ohne den Einzelbuchstaben-Fall bleiben `koln`, `rossleben`-artige
URLs auf 404, obwohl die Stadt existiert. Beispiel-Kandidatenliste für `koln`:
`koln, köln, koeln, koln` (alle Versuche) — der `ö`-Versuch matcht die DB.
## ⚠️ Generator: nie nur die NÄCHSTEN Ziele nehmen — sonst verschwinden Fernziele (2026-08-09)
Beim automatischen Seeden von Drill-Down-Ebenen (Distanz-basiert, z.B. Ebene-2-Ziele
aus einer Städte-Referenzliste mit Haversine) ist die größte Daten-Falle das
**Abschneiden des fernen Endes**:
```python
# FALSCH: sortiert nach Distanz (nahe zuerst) und nimmt die ersten N
ziele.sort(key=lambda x: x[0]/70) # d/70 = h
for nach, ... in ziele[:N]: # N = z.B. 26
...
```
→ Das nimmt nur die **nächsten** Städte. Alle **5-7h- und >7h-Fernziele** fallen
raus, weil sie weiter weg sind. Symfony: Trier-Ebene2 hatte danach **nur "unter 5h"**
(alle 26), 0 Kernziele, 0 Fernziele. Der Nutzer bemerkte sofort: „Jetzt sind nur noch
die kleinen da — was ist mit den 5-7h und >7h passiert?"
**Fix — pro Kategorie die nahsten N, damit ALLE Kategorien vertreten bleiben:**
```python
kat = {"unter 5h":1, "Kernziel (5-7h)":2, "über 7h":3}[...] # h aus d/70
kategorien[kat].append(...)
# danach je Kategorie sortieren + je Kategorie max_pro_kat nehmen
for kat in ["unter 5h","Kernziel (5-7h)","über 7h"]:
kategorien[kat].sort(key=lambda x: dist)
ziele += kategorien[kat][:max_pro_kat] # z.B. max_pro_kat=9
```
Gib dadurch pro Startstadt z.B. 26 unter5h + 26 kern + 17 über7h statt nur 26 nah.
**Zweite Fall-Hälfte (Kategorie aus echter Zeit ableiten, NICHT aus Wunschbandbreite):**
Die Kategorie muss aus der **berechneten Stundenzahl** (`h<5 → unter5h`, `5≤h≤7 →
Kernziel(5-7h)`, `h>7 → über7h`) kommen. Vorher wurde `d<350km → 5-7h` als Distanz-
Bandbreite gesetzt, was Ziele mit `~3h` als "Kernziel 5-7h" labelte — also Label und
angezeigte Fahrtzeit widersprachen sich (Dinslaken ~3h als "5-7h"). Einheitlich: erst
`h = d/70` berechnen, dann Kategorie aus h. Prüf-Check nach dem Seed:
```sql
SELECT kategorie,COUNT(*) FROM verkehr WHERE ebene=2 GROUP BY kategorie -- alle 3 gefüllt?
```
Erwartung: nie eine Ebene, die nur 1 Kategorie enthält, solange es Fernziele <700km
gibt.
## ⚠️ PDF-Erwartung: je EINZELNE Verbindung, nicht Liste aller Ziele (2026-08-09)
Nutzer beim Betrachten der urlaub-Site: „Die PDF finde ich blöd. Ich habe gedacht
die PDF wäre für die Verbindung von → nach, nicht nur eine PDF mit allem." — Der
erste Entwurf erzeugte **eine PDF pro Startstadt mit ALLEN Zielen** (Liste). Erwartung
war aber: **eine PDF je konkreter Fahrt (Start → Ziel)**.
**Fix:** Route auf Paar umbauen `@app.route('/pdf/<von>/<nach>')` mit einem *losen*
`pdf_einzel.html`-Template (Abfahrt-Zeit/Ziel/Fahrtzeit/Weg/Ticket), das **genau eine**
Verbindung beschreibt. Links in der Tabelle zeigen dann auf
`/pdf/<von>/<nach>` (je Zeile), nicht auf `/pdf/<startstadt>`. Wichtig:
- Beide Slugs über den `resolve()`-Helper mit Umlaut-/Fallback-Match gegen DB auflösen.
- Existiert die Verbindung nicht in der DB, trotzdem eine saubere Wegzeile rendern
(„Richtwert (DB prüfen)") statt 404 — der Nutzer soll immer ein PDF bekommen.
- Nach Umbau die alte Listen-Route entfernen (`/pdf/<stadt>` → 404 bestätigen), sonst
bleibt die gewohnte Liste erreichbar und verwirrt.
- Dateiname je Verbindung: `<von>--<nach>.pdf` (vermeidet Überschreiben bei mehreren Fahrten).
**Grundsatz:** Bei "PDF pro X" immer zuerst klären, ob der Nutzer *ein Dokument je
Datensatz* (hier: Fahrt) oder *ein Sammeldokument je Gruppe* (Startstadt) will.
Im Zweifel Richtung "je einzelner Eintrag/Fahrt" gehen — das ist die intuitive Erwartung.
## Team-Audit: Sub-Agenten können VPS-Dateien nicht lesen (2026-08-09)
Ein Audit-Task, das einem Sub-Agenten nur einen Pfad gibt (`/opt/urlaub/app.py`,
`urlaub.db`) scheitert stets: Rechen-Agenten haben **keinen VPS-Lesezugriff** und
finden die Datei nicht → sie können den Code nicht gegenprüfen und brechen ab
(korrekt, statt zu erfinden). Für Code-/Daten-Audits musst du als Master die
**relevanten Inhalte in den Task einbetten** (Schema, Codesnippets, Beispiel-Zeilen)
oder den Audit selbst anhand des echten Systemstands durchführen. Sonst läuft ein
„Team-Audit" ins Leere. Dieses Session-Thema: 1 von 3 Audit-Tasks brachte nichts,
weil der Agent nur den Pfad bekam.
## ⚠️ Backend-Code gehört ebenfalls ins Team-Review (2026-08-08)
Der Nutzer fragte nach dem Bau zu Recht: „Habt ihr euch gegengecheckt?" — Die
Ebene-2-DATEN kamen vom Team, aber der **Flask-Code/PDF/DB-Link war solo gebaut
ohne Review**. Die Review-Runde fand SOFORT echte Bugs:
- DB-Deep-Link `#query=`-Format war falsch (→ korrektes `query.exe/dn`, siehe
`deutsche-bahn-timetable-data.md`).
- Einwohner-Platzhalter (s.o.).
**Lektion:** Nicht nur fachliche Daten durchs Team prüfen lassen, sondern auch den
Deployment-Code. Ein kurzer Review-Task mit eingebettetem Kontext („Bitte analysiere
den Deep-Link + Platzhalter, nenne Fixes") findet solche Fehler günstig und schnell —
genau das „4 Agents > 1 teurer Agent"-Prinzip des Nutzers.