This commit is contained in:
team3
2026-06-24 11:56:12 +02:00
parent e985f07696
commit caa9314de5
15 changed files with 391 additions and 23 deletions

View File

@@ -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(

View File

@@ -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 — 37 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}

View File

@@ -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))

View File

@@ -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

View File

@@ -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()

View File

@@ -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])