update
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user