diff --git a/backend/database.py b/backend/database.py index 8f17db7..459c5eb 100644 --- a/backend/database.py +++ b/backend/database.py @@ -546,7 +546,7 @@ async def set_baustein_absolviert(topic: str, baustein: str) -> bool: async def count_relevant_subs(topic: str, baustein: str) -> int: - """Anzahl relevanter Konsens-Subbausteine eines Bausteins (für den cap = 2×Subs).""" + """Anzahl relevanter Konsens-Subbausteine eines Bausteins (für den cap = 4×Subs).""" from textkit import _norm_titel db = await get_db() cursor = await db.execute( diff --git a/backend/guide.py b/backend/guide.py index 1c6d3b1..873e1df 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -15,6 +15,8 @@ import math from datetime import datetime, timezone from pathlib import Path +import uuid + from agents import run_agent from bausteine import _pdfs_konvertieren, quelle_ordner from config import ( @@ -22,9 +24,9 @@ from config import ( LESBARKEIT_AKTIV, TEMPLATES_DIR, ) import lesbarkeit -from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content +from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content, get_guide_content from fsutil import atomic_write_json, atomic_write_text -from jsonio import read_json_file as _json_datei +from jsonio import read_json_file as _json_datei, parse_json_text as _parse_json_text from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path from pipeline import ( CANCELLED, FAILED, GenContext, _claude_error, _extra, @@ -816,3 +818,88 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio await _fail(guide_id, str(e)[:2000]) finally: clear_guide_cancelled(guide_id) + + +# --- On-Demand: eine Section prüfen / beheben / neu schreiben (Fokus, interaktiv) --- + +SECTION_PRUEFEN_TIMEOUT = 300 +SECTION_FIX_TIMEOUT = 600 + + +def _section_spec() -> str: + return (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8") + + +def _section_facts(topic: str) -> str: + project = quelle_ordner(topic) + return _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema") + + +def _hinweis_block(hinweis: str) -> str: + hinweis = (hinweis or "").strip() + return f"HINWEIS DES NUTZERS (besonders beachten):\n{hinweis}" if hinweis else "" + + +async def _subs_text(topic: str, baustein: str) -> str: + """Relevante Subbausteine eines Bausteins mit Stufe — als Checkliste für die Agenten.""" + subs_raw = await _load_subbausteine(topic) + subs = [s for s in subs_raw.get(_titel(baustein), []) if s.get("relevanz") != "rand"] + if not subs: + return "(keine Subbausteine hinterlegt — 3–7 knappe Punkte abdecken)" + return "\n".join(f"- [{s['stufe']}] {s['titel']}" for s in subs) + + +async def _guide_content_laden(topic: str, format_name: str) -> dict | None: + js = await get_guide_content(topic, format_name) + if not js: + return None + try: + return json.loads(js) + except ValueError: + return None + + +def _section_finden(content: dict, baustein: str) -> dict | None: + for ch in content.get("chapters", []): + for s in ch.get("sections", []): + if s.get("title") == baustein: + return s + return None + + +async def block_pruefen(topic: str, format_name: str, baustein: str, stelle: str, block: str, hinweis: str = "", provider: str = DEFAULT_PROVIDER) -> str | None: + """Einen Abschnitt (Markdown-Block) gegen die Guide-Regeln prüfen → korrigierte + Block-Version als Markdown. None = Fehler/Section fehlt.""" + content = await _guide_content_laden(topic, format_name) + sec = _section_finden(content, baustein) if content else None + if sec is None: + return None + ganze = sec.get("kompakt", "") if str(stelle).startswith("kompakt") else sec.get("md", "") + prompt = _prompt( + "Block-Pruefen", topic=topic, spec=_section_spec(), facts=_section_facts(topic), + subbausteine=await _subs_text(topic, baustein), kontext=ganze, block=block, hinweis=_hinweis_block(hinweis), + ) + rc, stdout, _ = await run_agent( + f"block-pruefen-{uuid.uuid4()}", prompt, SECTION_PRUEFEN_TIMEOUT, + provider=provider, role="judge", capabilities="none", lane="interactive", + ) + neu = stdout.strip() if rc == 0 else "" + return neu or None + + +async def block_uebernehmen(topic: str, format_name: str, baustein: str, stelle: str, alt: str, neu: str) -> dict | None: + """Einen Block (alt→neu) im Feld kompakt/ausführlich ersetzen + persistieren. + → {kompakt, md, gefunden}; None = Section fehlt.""" + content = await _guide_content_laden(topic, format_name) + sec = _section_finden(content, baustein) if content else None + if sec is None: + return None + feld = "kompakt" if str(stelle).startswith("kompakt") else "md" + aktuell = sec.get(feld, "") or "" + gefunden = alt in aktuell + if gefunden: + sec[feld] = aktuell.replace(alt, neu, 1) + js = json.dumps(content, ensure_ascii=False) + await set_guide_content(topic, format_name, js) + atomic_write_json(guide_content_path(topic, format_name), content, indent=1) + return {"kompakt": sec.get("kompakt", ""), "md": sec.get("md", ""), "gefunden": gefunden} diff --git a/backend/lernen.py b/backend/lernen.py index df14186..6509976 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -28,8 +28,8 @@ STUFEN = (("anfaenger", 0.2), ("fortgeschritten", 0.4), ("experte", 0.6), ("meis def cap_fuer(n_subs: int) -> int: - """Maximal-Score eines Bausteins: 2 Punkte je relevantem Subbaustein, gedeckelt [10, 50].""" - return max(10, min(50, 2 * n_subs)) + """Maximal-Score eines Bausteins: 4 Punkte je relevantem Subbaustein, gedeckelt [10, 50].""" + return max(10, min(50, 4 * n_subs)) def _schwelle(p: float, cap: int) -> int: @@ -84,7 +84,7 @@ def score_berechnen(basis: int, delta: int, floor: int, cap: int) -> int: """Neuer Score · driftfrei aus der Basis. `delta` liefert die Route je Prüfungsform (Quiz +1/+3, Lückentext +1/+3, Erklären −1…+3; falsch = progressiver_malus). Auf [floor, cap] geklemmt. floor = höchste erreichte Stufenschwelle (kein Rückfall); - cap = 2× relevante Subbausteine [10, 50]. Am Deckel (basis ≥ cap) eingefroren („Max").""" + cap = 4× relevante Subbausteine [10, 50]. Am Deckel (basis ≥ cap) eingefroren („Max").""" if basis >= cap: return basis return max(floor, min(cap, basis + delta)) diff --git a/backend/models.py b/backend/models.py index df5b88e..1463d8c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -230,8 +230,8 @@ class BausteinPruefungRequest(BaseModel): nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback) - # Basis + cap werden serverseitig geführt (Anker bzw. 2×Subs) — Client-cap nur Hinweis. - cap: int = Field(default=10, ge=1, le=50) # Score-Deckel = clamp(2×relevante Subbausteine, 10, 50) + # Basis + cap werden serverseitig geführt (Anker bzw. 4×Subs) — Client-cap nur Hinweis. + cap: int = Field(default=10, ge=1, le=50) # Score-Deckel = clamp(4×relevante Subbausteine, 10, 50) messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage provider: ProviderType = "claude" gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide) @@ -253,14 +253,42 @@ class BausteinPruefungResponse(BaseModel): loesung: str | None = None # Lückentext: erwarteter Begriff alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme gute_antworten: int - cap: int = 10 # Maximal-Score dieses Bausteins (2×Subs, geklemmt) — Frontend leitet die Stufe ab + cap: int = 10 # Maximal-Score dieses Bausteins (4×Subs, geklemmt) — Frontend leitet die Stufe ab class BausteinLernstand(BaseModel): gute_antworten: int streak: int = 0 - cap: int = 10 # 2×relevante Subbausteine, geklemmt [10, 50] + cap: int = 10 # 4×relevante Subbausteine, geklemmt [10, 50] class BausteinLernstandResponse(BaseModel): bausteine: dict[str, BausteinLernstand] + + +# --- Block-Inhalt: einen Abschnitt on-demand prüfen + übernehmen (Fokus, Rechtsklick) --- + +class BlockPruefenRequest(BaseModel): + baustein: str = Field(min_length=1, max_length=200) + stelle: str = "ausführlich" # "kompakt" | "ausführlich" (angezeigtes Feld) + block: str = Field(min_length=1, max_length=20000) # roher Markdown-Block + hinweis: str = Field(default="", max_length=2000) # optionaler Zusatz (✏️) + provider: ProviderType = "claude" + + +class BlockPruefenResponse(BaseModel): + neu: str # korrigierter Block als Markdown + + +class BlockUebernehmenRequest(BaseModel): + baustein: str = Field(min_length=1, max_length=200) + stelle: str = "ausführlich" + alt: str = Field(min_length=1, max_length=20000) + neu: str = Field(default="", max_length=20000) + provider: ProviderType = "claude" + + +class BlockUebernehmenResponse(BaseModel): + kompakt: str + md: str + gefunden: bool diff --git a/backend/regeln.py b/backend/regeln.py index 42111d3..7fcb0b6 100644 --- a/backend/regeln.py +++ b/backend/regeln.py @@ -37,7 +37,7 @@ async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, d """Guides + Kapitel-Fortschritt + Bausteine je Stufe. levels: {"anfaenger"/"fortgeschritten"/"experte"/"meister": {topic → normalisierte Titel}}. - Stufe je Baustein wird aus Score + cap (2×relevante Subs) abgeleitet. + Stufe je Baustein wird aus Score + cap (4×relevante Subs) abgeleitet. """ scores = await list_baustein_scores_all() caps = await subs_count_alle() diff --git a/backend/routes.py b/backend/routes.py index a52aa6c..d0cd114 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -20,7 +20,7 @@ from database import ( from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion from lernen import NIVEAUS, cap_fuer, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, score_berechnen, floor_aus_score, progressiver_malus, stufe_aus_score -from guide import generate_guide, guide_slot_dateien, guide_fertig_step +from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen from pipeline import cancel_guide from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen from models import ( @@ -35,6 +35,7 @@ from models import ( FolderResponse, BausteineQuelleUpdate, BausteineQuelleResponse, BausteinUebersicht, BausteinChatRequest, BausteinChatResponse, BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse, + BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse, ) from paths import bausteine_topics, guide_content_path, project_dir, topic_dir, quelle_path, safe_ordner from fsutil import atomic_write_json @@ -221,7 +222,7 @@ async def get_frage_muster(topic: str, baustein: str): @router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse) async def baustein_lernstand(topic: str): - """Prüfungs-Stand pro Baustein (roher Titel als Key). cap = 2×relevante Subs [10,50] + """Prüfungs-Stand pro Baustein (roher Titel als Key). cap = 4×relevante Subs [10,50] — für ALLE Bausteine (auch noch nicht geprüfte), damit der cap sofort stimmt.""" progress = {p["baustein"]: p for p in await list_baustein_progress(topic)} caps = await subs_count_roh(topic) @@ -281,7 +282,7 @@ def _erklaeren_delta(niveau: str, nachgefragt: bool) -> int: async def _cap_fuer(topic: str, baustein: str) -> int: - """Maximal-Score des Bausteins = clamp(2×relevante Subbausteine, 10, 50).""" + """Maximal-Score des Bausteins = clamp(4×relevante Subbausteine, 10, 50).""" return cap_fuer(await count_relevant_subs(topic, baustein)) @@ -517,6 +518,31 @@ async def guide_chat(guide_id: str, req: GuideChatRequest): return {"reply": reply} +async def _guide_tf(guide_id: str) -> tuple[str, str]: + guide = await get_guide(guide_id) + if guide is None: + raise HTTPException(404, "Guide nicht gefunden") + return guide["topic"], guide["format"] + + +@router.post("/guides/{guide_id}/block/pruefen", response_model=BlockPruefenResponse) +async def block_pruefen_route(guide_id: str, req: BlockPruefenRequest): + topic, fmt = await _guide_tf(guide_id) + neu = await block_pruefen(topic, fmt, req.baustein, req.stelle, req.block, req.hinweis, provider=req.provider) + if neu is None: + raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen") + return {"neu": neu} + + +@router.post("/guides/{guide_id}/block/uebernehmen", response_model=BlockUebernehmenResponse) +async def block_uebernehmen_route(guide_id: str, req: BlockUebernehmenRequest): + topic, fmt = await _guide_tf(guide_id) + res = await block_uebernehmen(topic, fmt, req.baustein, req.stelle, req.alt, req.neu) + if res is None: + raise HTTPException(404, "Section nicht gefunden") + return res + + # --- Elemente (persönliche Zusammenfassung) --- @router.get("/elements", response_model=list[ElementResponse]) diff --git a/frontend/src/api.js b/frontend/src/api.js index ac0745e..933ba21 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -152,6 +152,24 @@ export async function fetchGuideContent(id) { return res.json() } +// Einen Markdown-Block on-demand gegen die Guide-Regeln prüfen (Fokus, Rechtsklick). +export async function pruefeBlock(id, { baustein, stelle, block, hinweis = '', provider }) { + const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baustein, stelle, block, hinweis, provider }), + }) + return jsonOrThrow(res) +} + +// Geprüften Block persistent übernehmen (alt → neu im jeweiligen Feld). +export async function uebernehmeBlock(id, { baustein, stelle, alt, neu, provider }) { + const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baustein, stelle, alt, neu, provider }), + }) + return jsonOrThrow(res) +} + export async function fetchTopics() { const res = await fetch(`${BASE}/topics`) return res.json() diff --git a/frontend/src/assets/markdown.css b/frontend/src/assets/markdown.css index 3d55e75..050a6cb 100644 --- a/frontend/src/assets/markdown.css +++ b/frontend/src/assets/markdown.css @@ -7,11 +7,22 @@ margin: 0 0 0.5em; } -/* KaTeX: lange Block-Formeln scrollen statt das Layout zu sprengen */ +/* KaTeX: lange Block-Formeln scrollen statt das Layout zu sprengen. + Sichtbarer dünner Scrollbalken (statt unsichtbarem Overlay) → man erkennt, + dass die Formel weitergeht und nicht abgeschnitten ist. */ .markdown .katex-display { overflow-x: auto; overflow-y: hidden; - padding: 0.2em 0; + padding: 0.2em 0 0.45em; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +.markdown .katex-display::-webkit-scrollbar { + height: 6px; +} +.markdown .katex-display::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; } .markdown p:last-child { diff --git a/frontend/src/components/BausteinFokus.vue b/frontend/src/components/BausteinFokus.vue index ec69cce..aee9ce6 100644 --- a/frontend/src/components/BausteinFokus.vue +++ b/frontend/src/components/BausteinFokus.vue @@ -1,7 +1,8 @@