Backend: WAL+busy_timeout, DB↔Datei-Reconcile beim Start, zentraler JSON-Parser (jsonio)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,9 @@ async def get_db() -> aiosqlite.Connection:
|
|||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
|
# WAL übersteht Crashes deutlich besser; busy_timeout fängt kurze Locks ab.
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await db.execute("PRAGMA busy_timeout=5000")
|
||||||
await db.execute(CREATE_GUIDES)
|
await db.execute(CREATE_GUIDES)
|
||||||
await db.execute(CREATE_PROGRESS)
|
await db.execute(CREATE_PROGRESS)
|
||||||
await db.execute(CREATE_TOPICS)
|
await db.execute(CREATE_TOPICS)
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ from config import (
|
|||||||
TIMEOUTS,
|
TIMEOUTS,
|
||||||
MAX_CONCURRENT_GENERATIONS,
|
MAX_CONCURRENT_GENERATIONS,
|
||||||
)
|
)
|
||||||
from database import update_guide
|
from database import list_guides, update_guide
|
||||||
from fsutil import atomic_write_json, atomic_write_text
|
from fsutil import atomic_write_json, atomic_write_text
|
||||||
|
from jsonio import parse_json_text as _parse_json_text, read_json_file as _json_datei
|
||||||
from paths import arbeit_dir, bausteine_path, guide_content_path, project_dir
|
from paths import arbeit_dir, bausteine_path, guide_content_path, project_dir
|
||||||
|
|
||||||
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
|
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
|
||||||
@@ -109,19 +110,6 @@ def _titel_index(entries: dict[int, str]) -> dict[str, int]:
|
|||||||
return {_norm_titel(_titel(text)): num for num, text in entries.items()}
|
return {_norm_titel(_titel(text)): num for num, text in entries.items()}
|
||||||
|
|
||||||
|
|
||||||
def _json_datei(path: Path):
|
|
||||||
"""Liest eine JSON-Datei (Code-Fences tolerant); None bei fehlend/ungültig."""
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
text = path.read_text(encoding="utf-8").strip()
|
|
||||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text)
|
|
||||||
return json.loads(text)
|
|
||||||
except Exception as e:
|
|
||||||
log.debug("JSON-Datei ungültig: %s (%s)", path, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _timeout(step: str, n: int = 0) -> int:
|
def _timeout(step: str, n: int = 0) -> int:
|
||||||
base, per = TIMEOUTS[step]
|
base, per = TIMEOUTS[step]
|
||||||
return base + per * n
|
return base + per * n
|
||||||
@@ -1267,6 +1255,19 @@ async def _generate_sections(
|
|||||||
return chapters
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_guides() -> None:
|
||||||
|
"""DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error.
|
||||||
|
|
||||||
|
Läuft beim Server-Start (nach init_db) — fängt Crashes zwischen
|
||||||
|
Datei-Write und Status-Update ab.
|
||||||
|
"""
|
||||||
|
for g in await list_guides():
|
||||||
|
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
|
||||||
|
log.warning("[%s] Guide %s: done ohne Content-Datei — auf error gesetzt", g["topic"], g["id"])
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
await update_guide(g["id"], status="error", error_msg="Inhalt fehlt — neu generieren", updated_at=now)
|
||||||
|
|
||||||
|
|
||||||
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||||
async with _semaphore:
|
async with _semaphore:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
@@ -1354,32 +1355,6 @@ async def chat_with_guide(topic: str, format_name: str, section: str, outline: s
|
|||||||
|
|
||||||
# --- Elemente (persönliche Zusammenfassung) ---
|
# --- Elemente (persönliche Zusammenfassung) ---
|
||||||
|
|
||||||
def _parse_json_text(text: str):
|
|
||||||
"""Parst JSON aus KI-Output (Code-Fences und Drumherum-Text tolerant).
|
|
||||||
|
|
||||||
Repariert unescapte Anführungszeichen in Strings (z. B. MiniMax: "Titel „p" geändert"):
|
|
||||||
das letzte `"` vor der Fehlerstelle escapen und erneut parsen.
|
|
||||||
"""
|
|
||||||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
|
|
||||||
start, end = text.find("{"), text.rfind("}")
|
|
||||||
if start == -1 or end <= start:
|
|
||||||
return None
|
|
||||||
candidate = text[start:end + 1]
|
|
||||||
for _ in range(20):
|
|
||||||
try:
|
|
||||||
return json.loads(candidate)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
if not e.msg.startswith(("Expecting ',' delimiter", "Expecting ':' delimiter")):
|
|
||||||
return None
|
|
||||||
q = candidate.rfind('"', 0, e.pos)
|
|
||||||
if q <= 0:
|
|
||||||
return None
|
|
||||||
candidate = candidate[:q] + '\\"' + candidate[q + 1:]
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _element_fields(data: dict) -> dict | None:
|
def _element_fields(data: dict) -> dict | None:
|
||||||
"""Validiert KI-Element-JSON und normalisiert auf die DB-Felder."""
|
"""Validiert KI-Element-JSON und normalisiert auf die DB-Felder."""
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
|
|||||||
49
backend/jsonio.py
Normal file
49
backend/jsonio.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Toleranter JSON-Parser für KI-Output — als Text oder aus Dateien.
|
||||||
|
|
||||||
|
Verkraftet Code-Fences, Drumherum-Text und unescapte Anführungszeichen in
|
||||||
|
Strings (z. B. MiniMax: "Titel „p" geändert"): das letzte `"` vor der
|
||||||
|
Fehlerstelle wird escapet und erneut geparst.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger("creator.jsonio")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_text(text: str):
|
||||||
|
"""Parst JSON aus KI-Output; None bei nicht reparierbarem Input."""
|
||||||
|
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
|
||||||
|
start, end = text.find("{"), text.rfind("}")
|
||||||
|
if start == -1 or end <= start:
|
||||||
|
return None
|
||||||
|
candidate = text[start:end + 1]
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
return json.loads(candidate)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
if not e.msg.startswith(("Expecting ',' delimiter", "Expecting ':' delimiter")):
|
||||||
|
return None
|
||||||
|
q = candidate.rfind('"', 0, e.pos)
|
||||||
|
if q <= 0:
|
||||||
|
return None
|
||||||
|
candidate = candidate[:q] + '\\"' + candidate[q + 1:]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_file(path: Path):
|
||||||
|
"""Liest eine JSON-Datei mit derselben Toleranz; None bei fehlend/ungültig."""
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = parse_json_text(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("JSON-Datei nicht lesbar: %s (%s)", path, e)
|
||||||
|
return None
|
||||||
|
if data is None:
|
||||||
|
log.debug("JSON-Datei ungültig: %s", path)
|
||||||
|
return data
|
||||||
@@ -9,6 +9,7 @@ setup_logging()
|
|||||||
|
|
||||||
from config import FRONTEND_DIST, STORAGE_DIR
|
from config import FRONTEND_DIST, STORAGE_DIR
|
||||||
from database import init_db, close_db
|
from database import init_db, close_db
|
||||||
|
from generator import reconcile_guides
|
||||||
from routes import router
|
from routes import router
|
||||||
|
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ from routes import router
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
(STORAGE_DIR / "themen").mkdir(parents=True, exist_ok=True)
|
(STORAGE_DIR / "themen").mkdir(parents=True, exist_ok=True)
|
||||||
await init_db()
|
await init_db()
|
||||||
|
await reconcile_guides()
|
||||||
yield
|
yield
|
||||||
await close_db()
|
await close_db()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user