update
This commit is contained in:
9
Makefile
9
Makefile
@@ -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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user