diff --git a/backend/config.py b/backend/config.py index def4e34..440b52a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -15,6 +15,7 @@ TIMEOUTS = { "recherche": (1800, 0), # fix 30 min "auswahl": (600, 10), "auswahl_check": (300, 2), + "ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche) "guide_auswahl": (300, 5), # pro Baustein im Inventar "guide_check": (300, 2), # Auswahl-/Gliederungs-Prüfung (nur Titellisten) "plan": (300, 5), diff --git a/backend/generator.py b/backend/generator.py index f81f831..be6a475 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -2,6 +2,7 @@ import asyncio import json import math import shutil +import subprocess import re import uuid from datetime import datetime, timezone @@ -26,7 +27,7 @@ async def cancel_guide(guide_id: str) -> bool: _cancelled.add(guide_id) kill_process(guide_id) now = datetime.now(timezone.utc).isoformat() - await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now) + await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen — Fortschritt bleibt erhalten", updated_at=now) return True @@ -201,6 +202,13 @@ BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung") _CATEGORIES = ("KERN", "WICHTIG", "REST") # nur noch für den Altformat-Reader +def _bausteine_steps(topic: str) -> tuple: + """Projekte haben einen 4. Schritt: Themenfeld-Ergänzung per Web-Recherche.""" + if project_dir(topic).is_dir(): + return BAUSTEINE_STEPS + ("Ergänzung",) + return BAUSTEINE_STEPS + + def _bausteine_files(topic: str) -> dict: arbeit = arbeit_dir(topic) return { @@ -209,11 +217,12 @@ def _bausteine_files(topic: str) -> dict: "recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4)], "auswahl": [arbeit / f"auswahl-{i}.md" for i in (1, 2)], "auswahl_check": arbeit / "auswahl-check.json", + "ergaenzung": arbeit / "ergaenzung.json", } def _alle_slot_dateien(files: dict) -> list[Path]: - return [*files["recherche"], *files["auswahl"], files["auswahl_check"]] + return [*files["recherche"], *files["auswahl"], files["auswahl_check"], files["ergaenzung"]] def cancel_bausteine(topic: str) -> bool: @@ -233,10 +242,13 @@ def _resume_step(topic: str) -> int: return 1 if not files["auswahl_check"].exists(): return 2 - return 3 + if project_dir(topic).is_dir() and not files["ergaenzung"].exists(): + return 3 + return len(_bausteine_steps(topic)) def bausteine_status(topic: str) -> dict: + steps = _bausteine_steps(topic) ready = bausteine_path(topic).exists() generating = topic in _bausteine_progress partial = False @@ -244,21 +256,21 @@ def bausteine_status(topic: str) -> dict: current = _bausteine_step.get(topic) states = [ "pending" if current is None else "done" if i < current else "active" if i == current else "pending" - for i in range(len(BAUSTEINE_STEPS)) + for i in range(len(steps)) ] elif ready: - states = ["done"] * len(BAUSTEINE_STEPS) + states = ["done"] * len(steps) else: nxt = _resume_step(topic) partial = nxt > 0 - states = ["done" if i < nxt else "pending" for i in range(len(BAUSTEINE_STEPS))] + states = ["done" if i < nxt else "pending" for i in range(len(steps))] return { "ready": ready, "generating": generating, "progress": _bausteine_progress.get(topic), "error": _bausteine_errors.get(topic), "partial": partial, - "steps": [{"label": label, "state": s} for label, s in zip(BAUSTEINE_STEPS, states)], + "steps": [{"label": label, "state": s} for label, s in zip(steps, states)], } @@ -273,6 +285,41 @@ def reset_bausteine(topic: str) -> None: _bausteine_errors.pop(topic, None) +def _ergaenzung_schema(data): + """{"bausteine": [{"titel", "beschreibung"}]} → Liste (leer erlaubt) · sonst None.""" + if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list): + return None + out = [] + for b in data["bausteine"]: + if not isinstance(b, dict) or not isinstance(b.get("titel"), str) or not isinstance(b.get("beschreibung"), str): + return None + titel, beschreibung = b["titel"].strip(), b["beschreibung"].strip() + if not titel: + return None + out.append((titel, beschreibung)) + return out + + +def _pdfs_konvertieren(project: Path) -> None: + """PDFs im Projekt in .txt wandeln (pdftotext) — Agenten lesen Text statt Seiten-Bildern. + + Wird vor jeder Projekt-Generierung aufgerufen; konvertiert nur, wenn die + .txt fehlt oder älter als das PDF ist. Das Original bleibt unangetastet. + """ + if shutil.which("pdftotext") is None: + _log(project.name, "pdftotext nicht installiert — PDFs bleiben unkonvertiert") + return + for pdf in project.rglob("*.pdf"): + txt = pdf.with_suffix(".txt") + if txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime: + continue + try: + subprocess.run(["pdftotext", "-layout", str(pdf), str(txt)], check=True, timeout=120) + _log(project.name, f"PDF konvertiert: {pdf.name} → {txt.name}") + except Exception as e: + _log(project.name, f"PDF-Konvertierung fehlgeschlagen ({pdf.name}): {e}") + + def _build_recherche_prompt(topic: str, out_path: Path, instructions: str = "", project: Path | None = None) -> str: if project: source = _prompt("Bausteine-Quelle-Projekt", project=project) @@ -384,6 +431,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = try: async with _semaphore: files["arbeit"].mkdir(parents=True, exist_ok=True) + if project: + await asyncio.to_thread(_pdfs_konvertieren, project) # „Neu erstellen": fertige Bausteine → kompletter Frischstart. # Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume. if final_path.exists(): @@ -486,6 +535,40 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = texts = [t for _, t in sorted(entries.items())] + list(patch["nachtraege"]) entries = {i: t for i, t in enumerate(texts, 1)} + # Schritt 4 (nur Projekte): Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt, + # ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung]. + if project: + set_p("Ergänze Themenfeld…", step=3) + erg_path = files["ergaenzung"] + ergaenzungen = _ergaenzung_schema(_json_datei(erg_path)) + if ergaenzungen is None: + erg_path.unlink(missing_ok=True) + slots = [{ + "key": f"bausteine-{topic}-ergaenzung-1", + "prompt": _prompt( + "Bausteine-Ergaenzung", + topic=topic, bausteine="\n".join(f"- {t}" for t in entries.values()), + out_path=erg_path, extra=_extra(instructions), + ), + "role": "quick", "capabilities": "full", + "payload": (lambda result: _ergaenzung_schema(_json_datei(erg_path))), + }] + res = await _race(topic, "Ergänzung", slots, 1, _timeout("ergaenzung"), provider, cancelled=is_cancelled) + if is_cancelled(): + abgebrochen() + return + if res is None: + _bausteine_errors[topic] = "Ergänzung fehlgeschlagen (kein gültiges Ergebnis)" + return + ergaenzungen = res[0] + idx = _titel_index(entries) + neu = [(t, b) for t, b in ergaenzungen if _titel_aufloesen(idx, t) is None] + if neu: + _log(topic, f"Ergänzung: {len(neu)} Baustein(e) aus dem Themenfeld ergänzt") + start = max(entries, default=0) + 1 + for off, (t, b) in enumerate(neu): + entries[start + off] = f"{t} — {b} [Ergänzung]" + # Titel eindeutig machen und unsortiertes Inventar schreiben entries = _eindeutige_titel(entries) final_path.write_text( @@ -1192,6 +1275,9 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio if guide_id in _cancelled: return + if project: + await asyncio.to_thread(_pdfs_konvertieren, project) + # „Neu erstellen": fertiger Guide → kompletter Frischstart. # Sonst sind Schritt-Dateien Reste eines Abbruchs/Fehlers → Resume. if content_path.exists(): diff --git a/backend/routes.py b/backend/routes.py index 861f3b9..7bd0905 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -169,8 +169,10 @@ async def create(req: GuideCreateRequest): for g in await list_guides(): if g["topic"] == req.topic.strip() and g["format"] == req.format and g["status"] in ("queued", "generating"): raise HTTPException(409, "Generierung läuft bereits") - # Lernschulden-Regel: neue Guides nur, wenn das Format weniger als 5 offene hat (erstellt, nicht absolviert) - if req.format != "OnePager" and not guide_content_path(req.topic.strip(), req.format).exists(): + # Lernschulden-Regel: neue Guides nur, wenn das Format weniger als 5 offene hat (erstellt, nicht absolviert). + # Resume (Schritt-Dateien vorhanden) ist ausgenommen — der Guide wurde bereits angefangen. + content = guide_content_path(req.topic.strip(), req.format) + if req.format != "OnePager" and not content.exists() and not guide_slot_dateien(content): stat = (await _formate_stats()).get(req.format, {"erstellt": 0, "absolviert": 0}) offen = stat["erstellt"] - stat["absolviert"] if offen >= MAX_OFFENE_GUIDES: @@ -240,16 +242,22 @@ async def cancel(guide_id: str): @router.delete("/guides/{guide_id}") -async def remove(guide_id: str): +async def remove(guide_id: str, slots: bool = False): guide = await get_guide(guide_id) if guide is None: raise HTTPException(404, "Guide nicht gefunden") - content = guide_content_path(guide["topic"], guide["format"]) - for p in guide_slot_dateien(content): - p.unlink(missing_ok=True) - content.unlink(missing_ok=True) await delete_progress(guide_id) await delete_guide(guide_id) + # Content-/Schritt-Dateien teilen sich alle Läufe eines Thema+Formats — erst löschen, + # wenn kein Eintrag sie mehr braucht. Teilfortschritt (Schritt-Dateien ohne fertigen + # Content) bleibt fürs Resume erhalten, außer es wird explizit verlangt (slots=1). + rest = [g for g in await list_guides() if g["topic"] == guide["topic"] and g["format"] == guide["format"]] + if not rest: + content = guide_content_path(guide["topic"], guide["format"]) + if slots or content.exists(): + for p in guide_slot_dateien(content): + p.unlink(missing_ok=True) + content.unlink(missing_ok=True) return {"ok": True} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 94e38e5..fbb0fea 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@