This commit is contained in:
Team3
2026-07-05 15:26:22 +02:00
parent 07e14fb82e
commit 250ea0b764
45 changed files with 1468 additions and 1452 deletions

View File

@@ -3,6 +3,7 @@ import json
import logging
import shutil
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException
@@ -19,6 +20,7 @@ from database import (
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
get_sub_artefakte, kanban_reset, delete_guide_board,
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
list_runs, get_db,
)
from textkit import _norm_title
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
@@ -72,6 +74,18 @@ async def get_stats():
return {"topics": len(topics), "formats": formats_stats(guides, levels)}
@router.get("/health")
async def health():
await (await get_db()).execute("SELECT 1")
return {"ok": True}
@router.get("/runs")
async def get_runs(topic: str, limit: int = 10):
"""Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id."""
return {"runs": await list_runs(topic, limit)}
@router.get("/topics/progress")
async def topic_progress(topic: str):
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
@@ -89,14 +103,28 @@ async def add_topic(req: TopicCreateRequest):
@router.delete("/topics")
async def remove_topic(topic: str):
guides = [g for g in await list_guides() if g["topic"] == topic]
status = await blocks_status(topic)
if status["generating"] or any(g["status"] == "generating" for g in guides):
raise HTTPException(409, "Generierung läuft — erst abbrechen")
await delete_topic(topic)
await delete_block_data(topic)
await delete_topic_pipeline(topic)
await delete_source(topic) # topic config (DB) — removed together with the topic
await delete_guide_content(topic)
shutil.rmtree(topic_dir(topic), ignore_errors=True)
# guides/Board/Kanban mitlöschen — GET /topics leitet Topics aus guides ab,
# sonst taucht das gelöschte Topic sofort wieder auf
for g in guides:
await delete_guide(g["id"])
await delete_guide_board(topic)
await kanban_reset(topic)
import qa
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
# rmtree (potenziell große Topic-Ordner) in den Threadpool — der Event-Loop bedient
# parallel laufende Flows/Polls, blockierendes Datei-I/O friert die alle ein
def _wipe():
shutil.rmtree(topic_dir(topic), ignore_errors=True)
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
await asyncio.to_thread(_wipe)
return {"ok": True}
@@ -325,9 +353,10 @@ async def blocks_completeness(topic: str):
counts = await kanban_stage_counts(topic)
inv = counts.get("inventory", {})
blocks = await list_blocks(topic, status="consensus")
subs = 0
for b in blocks:
subs += sum(1 for s in await list_subblocks(topic, b["title_norm"]) if s["status"] == "consensus")
# ein Query statt N+1 (pro Block ein list_subblocks) — in Python nach consensus zählen
consensus_blocks = {b["title_norm"] for b in blocks}
subs = sum(1 for s in await list_subblocks(topic)
if s["status"] == "consensus" and s["block_norm"] in consensus_blocks)
ziele = await list_lernziele(topic)
dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
degradiert = ueberstimmt = 0
@@ -466,15 +495,26 @@ async def block_chat_route(req: BlockChatRequest):
# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
# overwrite the absolute score with a stale base (race).
_check_locks: dict[tuple[str, str], asyncio.Lock] = {}
_check_locks: dict[tuple[str, str], tuple[asyncio.Lock, list]] = {}
def _check_lock(topic: str, block: str) -> asyncio.Lock:
@asynccontextmanager
async def _check_lock(topic: str, block: str):
"""Per-(topic,block)-Lock mit Refcount, das den Eintrag nach dem letzten Nutzer
entfernt — die Map wuchs sonst unbegrenzt (ein Lock pro je geprüftem Block)."""
key = (topic, block)
lock = _check_locks.get(key)
if lock is None:
lock = _check_locks[key] = asyncio.Lock()
return lock
entry = _check_locks.get(key)
if entry is None:
entry = _check_locks[key] = (asyncio.Lock(), [0])
lock, ref = entry
ref[0] += 1
try:
async with lock:
yield
finally:
ref[0] -= 1
if ref[0] == 0 and _check_locks.get(key) is entry:
del _check_locks[key]
def _basis(state: dict, question: str) -> tuple[int, bool]: