This commit is contained in:
team3
2026-07-10 17:14:17 +02:00
parent 58bb600ba3
commit 917b3ac702
6 changed files with 83 additions and 13 deletions

View File

@@ -40,15 +40,14 @@ server-push:
cd backend && python3 transfer.py export "$(TOPIC)" > /tmp/creator2-transfer.json 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 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 @# 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 storage/korpus/$(TOPIC) ] || rsync -az --delete --mkpath --chown=1000:1000 storage/korpus/$(TOPIC)/ $(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/
@[ ! -d topics/$(TOPIC) ] || rsync -az --delete topics/$(TOPIC)/ $(SERVER):$(REMOTE)/topics/$(TOPIC)/ @[ ! -d topics/$(TOPIC) ] || rsync -az --delete --mkpath --chown=1000:1000 topics/$(TOPIC)/ $(SERVER):$(REMOTE)/topics/$(TOPIC)/
@echo "Push fertig: $(TOPIC) → Server." @echo "Push fertig: $(TOPIC) → Server."
server-pull: server-pull:
@[ -n "$(TOPIC)" ] || { echo "Nutzung: make server-pull TOPIC=<thema>"; exit 1; } @[ -n "$(TOPIC)" ] || { echo "Nutzung: make server-pull TOPIC=<thema>"; exit 1; }
ssh $(SERVER) 'cd $(REMOTE) && docker compose run --rm --no-deps -T creator2 python3 transfer.py export $(TOPIC)' > /tmp/creator2-transfer.json 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 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 --mkpath $(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/ storage/korpus/$(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 --mkpath $(SERVER):$(REMOTE)/topics/$(TOPIC)/ topics/$(TOPIC)/
@ssh $(SERVER) '[ ! -d $(REMOTE)/topics/$(TOPIC) ]' || rsync -az --delete $(SERVER):$(REMOTE)/topics/$(TOPIC)/ topics/$(TOPIC)/
@echo "Pull fertig: $(TOPIC) ← Server." @echo "Pull fertig: $(TOPIC) ← Server."

View File

@@ -4,6 +4,8 @@ scrollt je Spalte; Deltas kommen über /ws."""
import asyncio import asyncio
import logging import logging
import subprocess
from pathlib import Path
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -16,7 +18,7 @@ import db
import guide import guide
import ledger import ledger
import pipeline import pipeline
from config import FRONTEND_DIST from config import FRONTEND_DIST, PROJECT_ROOT
from ws import hub from ws import hub
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
@@ -159,6 +161,30 @@ def health():
return {"ok": True} 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") @app.get("/api/topics/{topic}/guide")
def guide_holen(topic: str): def guide_holen(topic: str):
# kein markdown-Feld: das Frontend rendert aus kapitel; Roh-Markdown # kein markdown-Feld: das Frontend rendert aus kapitel; Roh-Markdown

View File

@@ -12,6 +12,10 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 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: networks:
- web - web
volumes: volumes:

View File

@@ -92,6 +92,20 @@ async function stoppen() {
await stateLaden() 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) { async function waehlen(name) {
topic.value = name topic.value = name
await stateLaden() await stateLaden()
@@ -101,6 +115,7 @@ let poll = null // Fallback, falls während langer LLM-Calls keine WS-Deltas kom
onMounted(async () => { onMounted(async () => {
themeAnwenden() themeAnwenden()
api.transferInfo().then((i) => { transferOk.value = i.verfuegbar }).catch(() => {})
await topicsLaden() await topicsLaden()
await stateLaden() await stateLaden()
wsSchliessen = wsVerbinden(onWs) wsSchliessen = wsVerbinden(onWs)
@@ -163,6 +178,15 @@ function runBadge(run) {
</div> </div>
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button> <button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button> <button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
<template v-if="transferOk">
<button class="sekundaer" title="Thema auf den Server pushen (überschreibt dort)"
:disabled="!topic || !!uebertragung || state?.run?.status === 'running'"
@click="uebertragen('push')"> Push</button>
<button class="sekundaer" title="Thema vom Server pullen (überschreibt lokal)"
:disabled="!topic || !!uebertragung || state?.run?.status === 'running'"
@click="uebertragen('pull')"> Pull</button>
<span v-if="uebertragung" class="badge blau">{{ uebertragung }}</span>
</template>
<span v-if="state?.run" class="badge" :class="runBadge(state.run).farbe">{{ runBadge(state.run).text }}</span> <span v-if="state?.run" class="badge" :class="runBadge(state.run).farbe">{{ runBadge(state.run).text }}</span>
<span v-if="state" class="badge">{{ tok(state.verbraucht) }} Tokens</span> <span v-if="state" class="badge">{{ tok(state.verbraucht) }} Tokens</span>
<span v-if="state?.run?.grund" class="badge gelb">{{ state.run.grund }}</span> <span v-if="state?.run?.grund" class="badge gelb">{{ state.run.grund }}</span>

View File

@@ -28,6 +28,9 @@ export const api = {
antwort: (artefakt_id, richtig) => antwort: (artefakt_id, richtig) =>
req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }), req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }),
kennzahlen: (runId) => req(`/api/runs/${runId}/kennzahlen`), 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) { export function wsVerbinden(onEvent) {

View File

@@ -41,7 +41,7 @@ const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
function hoehe(i) { function hoehe(i) {
const st = stats.value.je[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) return Math.round((st?.woerter || 200) / 11 * 28 + sections * 180)
} }
@@ -56,10 +56,24 @@ function levelToggle() {
laden() 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) // Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe) // + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
const stats = computed(() => { 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 woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
let formeln = 0 let formeln = 0
for (const s of kap.sections) { for (const s of kap.sections) {
@@ -102,7 +116,7 @@ function onScroll(e) {
<template> <template>
<div class="guide-layout" :class="{ fokus }"> <div class="guide-layout" :class="{ fokus }">
<nav v-if="daten?.kapitel?.length" class="guide-nav"> <nav v-if="anzeige.length" class="guide-nav">
<div class="schalter-zeile"> <div class="schalter-zeile">
<div class="schalter"> <div class="schalter">
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)" <button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
@@ -126,10 +140,10 @@ function onScroll(e) {
@click="fokus = true"></button> @click="fokus = true"></button>
</div> </div>
<div class="fortschritt"> <div class="fortschritt">
<div>Kapitel {{ aktiv + 1 }} / {{ daten.kapitel.length }}</div> <div>Kapitel {{ aktiv + 1 }} / {{ anzeige.length }}</div>
<div>{{ stats.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(stats.minuten) }}</div> <div>{{ stats.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(stats.minuten) }}</div>
</div> </div>
<a v-for="(kap, i) in daten.kapitel" :key="i" :class="{ aktiv: i === aktiv }" <a v-for="(kap, i) in anzeige" :key="kap.kapitel_id" :class="{ aktiv: i === aktiv }"
@click="springe(i)"> @click="springe(i)">
<span>{{ i + 1 }}. {{ kap.titel }}</span> <span>{{ i + 1 }}. {{ kap.titel }}</span>
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span> <span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
@@ -139,7 +153,7 @@ function onScroll(e) {
<button v-if="fokus" class="fokus-aus" title="Fokus beenden" <button v-if="fokus" class="fokus-aus" title="Fokus beenden"
@click="fokus = false"></button> @click="fokus = false"></button>
<template v-if="daten"> <template v-if="daten">
<div v-for="(kap, i) in daten.kapitel" :key="i" class="kapitel" <div v-for="(kap, i) in anzeige" :key="kap.kapitel_id" class="kapitel"
:ref="(el) => setKapEl(i, el)"> :ref="(el) => setKapEl(i, el)">
<h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2> <h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2>
<template v-if="sichtbar(i)"> <template v-if="sichtbar(i)">
@@ -152,7 +166,7 @@ function onScroll(e) {
</template> </template>
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div> <div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>
</div> </div>
<div v-if="!daten.kapitel.length" class="guide-section">Noch kein Guide generiert.</div> <div v-if="!anzeige.length" class="guide-section">Noch kein Guide generiert.</div>
</template> </template>
</div> </div>
</div> </div>