update
This commit is contained in:
@@ -2,7 +2,7 @@ import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
@@ -18,10 +18,12 @@ from database import (
|
||||
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
|
||||
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,
|
||||
)
|
||||
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
|
||||
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card
|
||||
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup
|
||||
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup, leitner_step, PRACTICE_NEW_PER_SESSION
|
||||
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
|
||||
from pipeline import cancel_guide
|
||||
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
|
||||
@@ -29,13 +31,14 @@ from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse,
|
||||
GuideCardResetRequest,
|
||||
GuideCardResetRequest, GuideFormatRequest,
|
||||
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
|
||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
||||
BlockChatRequest, BlockChatResponse,
|
||||
BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
|
||||
BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse,
|
||||
PracticeAnswerRequest,
|
||||
)
|
||||
from paths import blocks_topics, guide_content_path, project_dir, topic_dir, source_path, safe_folder
|
||||
from fsutil import atomic_write_json
|
||||
@@ -282,6 +285,43 @@ async def update_blocks_source(req: BlocksSourceUpdate):
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/blocks/completeness")
|
||||
async def blocks_completeness(topic: str):
|
||||
"""Beleg der Themen-Zerlegung: Bestand, Filter-Bilanz, Lernziele, Artefakte, Laufzeit."""
|
||||
import glob as _glob
|
||||
from pathlib import Path as _Path
|
||||
from paths import arbeit_dir
|
||||
from database import (kanban_stage_counts, list_blocks, list_subblocks, list_lernziele,
|
||||
count_question_pattern_blocks, count_sub_artefakte, event_span)
|
||||
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")
|
||||
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
|
||||
for p in _glob.glob(str(arbeit_dir(topic) / "inventar-filter*.json")):
|
||||
try:
|
||||
d = json.loads(_Path(p).read_text(encoding="utf-8"))
|
||||
degradiert += d.get("degradiert", 0)
|
||||
ueberstimmt += len(d.get("ueberstimmt", []))
|
||||
except Exception:
|
||||
continue
|
||||
status = await blocks_status(topic)
|
||||
return {
|
||||
"bloecke": len(blocks), "subs": subs,
|
||||
"verworfen": inv.get("rejected", 0), "zusammengelegt": inv.get("grouped", 0),
|
||||
"degradiert_geprueft": degradiert, "panel_gerettet": ueberstimmt,
|
||||
"ziele_total": len(ziele), "ziele_covered": sum(1 for z in ziele if z["covered"]),
|
||||
"frage_bloecke": await count_question_pattern_blocks(topic),
|
||||
"lernkarten": await count_sub_artefakte(topic),
|
||||
"dead": dead, "lauf_minuten": await event_span(topic),
|
||||
"vollstaendig": bool(status.get("ready")) and dead == 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/blocks/overview", response_model=list[BlockOverview])
|
||||
async def get_blocks_uebersicht(topic: str):
|
||||
return await load_overview(topic)
|
||||
@@ -313,6 +353,68 @@ async def get_artefakte(topic: str, type: str | None = None):
|
||||
return {"artefakte": out}
|
||||
|
||||
|
||||
# --- Practice deck: Leitner flashcard pool per topic ---
|
||||
|
||||
async def build_practice_deck(topic: str) -> dict:
|
||||
"""ONE stack per topic (spacing beats per-block mini-stacks): due cards first
|
||||
(oldest due_at), then up to PRACTICE_NEW_PER_SESSION new ones. Level gate via the
|
||||
block's exam score (freie_level) — locked cards are counted for transparency."""
|
||||
cards = await get_sub_artefakte(topic, "flashcard")
|
||||
levels = await sub_levels_norm(topic)
|
||||
n_je = await subs_per_level_norm(topic)
|
||||
progress = {_norm_title(p["block"]): p["good_answers"] for p in await list_block_progress(topic)}
|
||||
pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(topic)}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
due, new, future, gesperrt = [], [], [], 0
|
||||
for r in cards:
|
||||
bn, sn = r["block_norm"], r["sub_norm"]
|
||||
n_block = n_je.get(bn)
|
||||
if n_block is not None: # legacy blocks without level data pass unfiltered
|
||||
if levels.get((bn, sn), 1) > freie_level(progress.get(bn, 0), n_block):
|
||||
gesperrt += 1
|
||||
continue
|
||||
try:
|
||||
data = json.loads(r["data"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
card = {"block": r["block"], "block_norm": bn, "sub_norm": sn,
|
||||
"subblock": r["sub_title"], "question": data.get("question", ""),
|
||||
"answer": data.get("answer", "")}
|
||||
p = pp.get((bn, sn))
|
||||
if p is None:
|
||||
card.update(box=None, status="new")
|
||||
new.append(card)
|
||||
elif p["due_at"] <= now:
|
||||
card.update(box=p["box"], status="due", due_at=p["due_at"])
|
||||
due.append(card)
|
||||
else:
|
||||
future.append(p["due_at"])
|
||||
due.sort(key=lambda c: c["due_at"])
|
||||
new_total = len(new)
|
||||
new = new[:PRACTICE_NEW_PER_SESSION]
|
||||
return {"cards": due + new,
|
||||
"counts": {"due": len(due), "new": len(new), "new_total": new_total,
|
||||
"gesperrt": gesperrt},
|
||||
"next_due_at": min(future) if future else None}
|
||||
|
||||
|
||||
@router.get("/practice/deck")
|
||||
async def practice_deck(topic: str):
|
||||
return await build_practice_deck(topic)
|
||||
|
||||
|
||||
@router.post("/practice/answer")
|
||||
async def practice_answer(req: PracticeAnswerRequest):
|
||||
"""Book a Leitner step. Deliberately NO existence check against sub_artefakte:
|
||||
an answer during regeneration books instead of failing (worst case an orphan row)."""
|
||||
pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(req.topic)}
|
||||
prev = pp.get((req.block_norm, req.sub_norm))
|
||||
box, days = leitner_step(prev["box"] if prev else None, req.correct)
|
||||
due_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
||||
await upsert_practice_progress(req.topic, req.block_norm, req.sub_norm, box, due_at)
|
||||
return {"box": box, "due_at": due_at}
|
||||
|
||||
|
||||
# --- Block learning: chat, exam ---
|
||||
|
||||
@router.get("/blocks/learnstate", response_model=BlockLearnStateResponse)
|
||||
@@ -679,6 +781,25 @@ async def cancel(guide_id: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/guides/board/remove")
|
||||
async def remove_guide_format(req: GuideFormatRequest):
|
||||
"""Board-Remove: discard ALL runs of topic+format — old error rows pile up, and the
|
||||
per-guide delete keeps the board cards until the LAST row is gone (measured: 8 rows)."""
|
||||
doomed = [g for g in await list_guides() if g["topic"] == req.topic and g["format"] == req.format]
|
||||
if any(g["status"] in ("queued", "generating") for g in doomed):
|
||||
return {"ok": True, "status": "generating"}
|
||||
for g in doomed:
|
||||
await delete_progress(g["id"])
|
||||
await delete_guide(g["id"])
|
||||
await delete_guide_content(req.topic, req.format)
|
||||
await delete_guide_board(req.topic, req.format)
|
||||
content = guide_content_path(req.topic, req.format)
|
||||
for p in guide_slot_files(content):
|
||||
p.unlink(missing_ok=True)
|
||||
content.unlink(missing_ok=True)
|
||||
return {"ok": True, "removed": len(doomed)}
|
||||
|
||||
|
||||
@router.delete("/guides/{guide_id}")
|
||||
async def remove(guide_id: str, slots: bool = False):
|
||||
guide = await get_guide(guide_id)
|
||||
|
||||
Reference in New Issue
Block a user