From 917b3ac7022ea6e247a49abd4325a2a0a80fefdb Mon Sep 17 00:00:00 2001 From: team3 Date: Fri, 10 Jul 2026 17:14:17 +0200 Subject: [PATCH] update --- Makefile | 9 ++++----- backend/main.py | 28 +++++++++++++++++++++++++++- docker-compose.yml | 4 ++++ frontend/src/App.vue | 24 ++++++++++++++++++++++++ frontend/src/api.js | 3 +++ frontend/src/components/Guide.vue | 28 +++++++++++++++++++++------- 6 files changed, 83 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 90a9b85..df08abf 100644 --- a/Makefile +++ b/Makefile @@ -40,15 +40,14 @@ server-push: cd backend && python3 transfer.py export "$(TOPIC)" > /tmp/creator2-transfer.json ssh $(SERVER) 'cd $(REMOTE) && docker compose run --rm --no-deps -T creator2 python3 transfer.py import' < /tmp/creator2-transfer.json @# Dateien NACH dem Import — topic_loeschen räumt den Korpus-Ordner der Zielseite - @[ ! -d storage/korpus/$(TOPIC) ] || rsync -az --delete storage/korpus/$(TOPIC)/ $(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/ - @[ ! -d topics/$(TOPIC) ] || rsync -az --delete topics/$(TOPIC)/ $(SERVER):$(REMOTE)/topics/$(TOPIC)/ + @[ ! -d storage/korpus/$(TOPIC) ] || rsync -az --delete --mkpath --chown=1000:1000 storage/korpus/$(TOPIC)/ $(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/ + @[ ! -d topics/$(TOPIC) ] || rsync -az --delete --mkpath --chown=1000:1000 topics/$(TOPIC)/ $(SERVER):$(REMOTE)/topics/$(TOPIC)/ @echo "Push fertig: $(TOPIC) → Server." server-pull: @[ -n "$(TOPIC)" ] || { echo "Nutzung: make server-pull TOPIC="; exit 1; } ssh $(SERVER) 'cd $(REMOTE) && docker compose run --rm --no-deps -T creator2 python3 transfer.py export $(TOPIC)' > /tmp/creator2-transfer.json cd backend && python3 transfer.py import < /tmp/creator2-transfer.json - @mkdir -p storage/korpus/$(TOPIC) topics/$(TOPIC) - @ssh $(SERVER) '[ ! -d $(REMOTE)/storage/korpus/$(TOPIC) ]' || rsync -az --delete $(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/ storage/korpus/$(TOPIC)/ - @ssh $(SERVER) '[ ! -d $(REMOTE)/topics/$(TOPIC) ]' || rsync -az --delete $(SERVER):$(REMOTE)/topics/$(TOPIC)/ topics/$(TOPIC)/ + @ssh $(SERVER) '[ ! -d $(REMOTE)/storage/korpus/$(TOPIC) ]' || rsync -az --delete --mkpath $(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/ storage/korpus/$(TOPIC)/ + @ssh $(SERVER) '[ ! -d $(REMOTE)/topics/$(TOPIC) ]' || rsync -az --delete --mkpath $(SERVER):$(REMOTE)/topics/$(TOPIC)/ topics/$(TOPIC)/ @echo "Pull fertig: $(TOPIC) ← Server." diff --git a/backend/main.py b/backend/main.py index 70d72b0..ea53516 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,8 @@ scrollt je Spalte; Deltas kommen über /ws.""" import asyncio import logging +import subprocess +from pathlib import Path from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware @@ -16,7 +18,7 @@ import db import guide import ledger import pipeline -from config import FRONTEND_DIST +from config import FRONTEND_DIST, PROJECT_ROOT from ws import hub logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") @@ -159,6 +161,30 @@ def health(): return {"ok": True} +# Themen-Transfer nur im lokalen Dev-Betrieb: der Server-Container (erkennbar an +# /.dockerenv) hat weder ssh-Schlüssel noch das make-Gegenstück. +_TRANSFER_LOKAL = not Path("/.dockerenv").exists() + + +@app.get("/api/transfer") +def transfer_info(): + return {"verfuegbar": _TRANSFER_LOKAL} + + +@app.post("/api/topics/{topic}/transfer/{richtung}") +async def transfer_ausfuehren(topic: str, richtung: str): + if not _TRANSFER_LOKAL: + raise HTTPException(403, "Transfer nur lokal verfügbar") + if richtung not in ("push", "pull"): + raise HTTPException(404, "unbekannte Richtung") + res = await asyncio.to_thread( + subprocess.run, ["make", f"server-{richtung}", f"TOPIC={topic}"], + cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=600) + if res.returncode: + raise HTTPException(500, (res.stderr or res.stdout)[-400:]) + return {"ok": True} + + @app.get("/api/topics/{topic}/guide") def guide_holen(topic: str): # kein markdown-Feld: das Frontend rendert aus kapitel; Roh-Markdown diff --git a/docker-compose.yml b/docker-compose.yml index 7b2ed8d..cd9617e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,10 @@ services: interval: 30s timeout: 10s retries: 3 + # Traefik routet erst ab "healthy" — ohne dichten Start-Takt prüft Docker + # erstmals nach `interval` und die Seite ist ~30-60s lang 404 + start_period: 30s + start_interval: 2s networks: - web volumes: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ad34798..b0a331f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -92,6 +92,20 @@ async function stoppen() { await stateLaden() } +const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403) +const uebertragung = ref('') + +async function uebertragen(richtung) { + uebertragung.value = richtung === 'push' ? 'pusht' : 'pullt' + try { + fehler.value = '' + await api.transfer(topic.value, richtung) + await topicsLaden() + await stateLaden() + } catch (e) { fehler.value = String(e) } + finally { uebertragung.value = '' } +} + async function waehlen(name) { topic.value = name await stateLaden() @@ -101,6 +115,7 @@ let poll = null // Fallback, falls während langer LLM-Calls keine WS-Deltas kom onMounted(async () => { themeAnwenden() + api.transferInfo().then((i) => { transferOk.value = i.verfuegbar }).catch(() => {}) await topicsLaden() await stateLaden() wsSchliessen = wsVerbinden(onWs) @@ -163,6 +178,15 @@ function runBadge(run) { + {{ runBadge(state.run).text }} {{ tok(state.verbraucht) }} Tokens {{ state.run.grund }} diff --git a/frontend/src/api.js b/frontend/src/api.js index 9c7da46..aa8d158 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -28,6 +28,9 @@ export const api = { antwort: (artefakt_id, richtig) => req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }), kennzahlen: (runId) => req(`/api/runs/${runId}/kennzahlen`), + transferInfo: () => req('/api/transfer'), + transfer: (topic, richtung) => + req(`/api/topics/${topic}/transfer/${richtung}`, { method: 'POST', body: '{}' }), } export function wsVerbinden(onEvent) { diff --git a/frontend/src/components/Guide.vue b/frontend/src/components/Guide.vue index 3ff6ce5..97b9225 100644 --- a/frontend/src/components/Guide.vue +++ b/frontend/src/components/Guide.vue @@ -41,7 +41,7 @@ const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1 function hoehe(i) { const st = stats.value.je[i] - const sections = daten.value?.kapitel?.[i]?.sections?.length || 1 + const sections = anzeige.value[i]?.sections?.length || 1 return Math.round((st?.woerter || 200) / 11 * 28 + sections * 180) } @@ -56,10 +56,24 @@ function levelToggle() { laden() } +// Nur Kapitel/Bausteine mit Inhalt im gewählten Umfang anzeigen — der +// Level-Filter ließe sonst leere Titel-Hüllen stehen (36/66 Bausteine ohne E) +const anzeige = computed(() => { + const out = [] + for (const kap of daten.value?.kapitel || []) { + const sections = kap.sections.filter((s) => ansicht.value === 'kompakt' + ? (s.kompakt || s.lang || '').trim() + : lesestat(gefiltert(s.lang, level.value)).woerter >= 10) + if (sections.length) out.push({ ...kap, sections }) + } + return out +}) +watch([level, ansicht, daten], () => { kapEls.length = 0; aktiv.value = 0 }) + // Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text) // + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe) const stats = computed(() => { - const je = (daten.value?.kapitel || []).map((kap) => { + const je = anzeige.value.map((kap) => { let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter let formeln = 0 for (const s of kap.sections) { @@ -102,7 +116,7 @@ function onScroll(e) {