This commit is contained in:
Team3
2026-07-05 00:54:21 +02:00
parent 219993ffd5
commit db58d52567
5 changed files with 114 additions and 4 deletions

View File

@@ -703,6 +703,39 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
return {"columns": columns, "qa_guide": note_guide}
async def repair_karten(topic: str, format_name: str) -> list[str]:
"""QA-Befund-getriebenes Guide-Repair: Karten, die im jüngsten Guide-QA-Report
Befunde tragen, gehen zurück auf `pruefer` (md bleibt) — Prüfer+Fix beheben gezielt,
generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum
Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel."""
import qa as qa_mod
tdir = qa_mod.QA_DIR / topic
reports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
rep = _json_file(reports[-1]) if reports else None
if not rep:
return []
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
norms: set[str] = set()
for e in rep.get("marker_fehlend", []): # "Block · sub"
norms.add(_norm_title(str(e).split(" · ")[0]))
for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
norms.add(str(e).split(" · ")[0])
for e in rep.get("laengen_ausreisser", []): # {"block": titel}
norms.add(_norm_title(e.get("block", "") if isinstance(e, dict) else str(e)))
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
norms.add(_norm_title(str(e).split(":")[0]))
for t in rep.get("fachlich_falsch", []) or []:
norms.add(_norm_title(str(t)))
for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
for seite in ("a", "b"):
norms.add(_norm_title(str(e.get(seite, "")).split(":")[0]))
betroffen = []
for n in sorted(norms & set(cards)):
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
betroffen.append(cards[n]["block"])
return betroffen
async def reset_card(topic: str, format_name: str, block_norm: str, ab_stage: int) -> bool:
"""Reset ONE guide card to a stage (single-card variant of reset_from_stage):
fields re-zeroed, md only wiped for writer(2) and earlier, lernziele only for 0."""

View File

@@ -9,9 +9,9 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from agents import active_agents, provider_available
from config import PROJECTS_DIR, UNI_DIR, PROVIDERS
from config import DEFAULT_PROVIDER, PROJECTS_DIR, UNI_DIR, PROVIDERS
from database import (
create_guide, delete_guide, get_guide, list_guides,
create_guide, delete_guide, get_guide, list_guides, update_guide,
create_topic, list_topics as db_list_topics, delete_topic,
list_block_progress, get_block_progress, set_open_question,
set_block_score_and_streak,
@@ -681,6 +681,27 @@ async def get_guide_board(topic: str, format: str = "Guide"):
return snap
@router.post("/guides/board/repair")
async def repair_guide_board(req: GuideBoardResetRequest):
"""Befunde beheben fürs Guide-Board: Karten mit QA-Befunden zurück auf `pruefer`,
dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu."""
import guide_board
topic = req.topic.strip()
guide = next((g for g in await list_guides()
if g["topic"] == topic and g["format"] == req.format), None)
if guide is None:
raise HTTPException(404, "Kein Guide für dieses Topic/Format")
if guide["status"] in ("queued", "generating"):
return {"ok": True, "status": "generating", "betroffen": []}
betroffen = await guide_board.repair_karten(topic, req.format)
if betroffen:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide["id"], status="queued", progress="Repair…", updated_at=now)
asyncio.create_task(generate_guide(guide["id"], topic, req.format,
guide.get("instructions") or "", DEFAULT_PROVIDER))
return {"ok": True, "betroffen": betroffen}
@router.post("/guides/board/reset")
async def reset_guide_board(req: GuideBoardResetRequest):
"""Reset cards from a stage onward — without generation (pendant to blocks reset-stage)."""

View File

@@ -417,3 +417,29 @@ async def test_laengen_trigger_startet_fix(testdb, tmp_path, monkeypatch):
card.update(stage="fix", gate_info=(await db.list_guide_cards(TOPIC, FMT))[0]["gate_info"])
assert await gb._stage_fix(env, card)
assert "Länge" in seen["auftraege"] and str(gb._writer_budget(1)) in seen["auftraege"]
async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, monkeypatch):
"""Guide-Repair: Karten mit QA-Befunden (alle Befundklassen-Formate) → pruefer,
md bleibt; Karten ohne Befund unangetastet. Kein Report → leere Liste."""
import json as _json
import qa as qa_mod
db = testdb
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
for n in ("alpha", "beta", "gamma"):
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
assert await gb.repair_karten(TOPIC, FMT) == [] # kein Report
tdir = tmp_path / "qa" / TOPIC
tdir.mkdir(parents=True)
(tdir / "guide-20260705-000000.json").write_text(_json.dumps({
"marker_fehlend": ["Alpha · sub eins"],
"fachlich_falsch": ["Beta"],
"ziel_ohne_anker": [], "laengen_ausreisser": [], "redundanz": [], "lesbarkeit": [],
}), encoding="utf-8")
betroffen = await gb.repair_karten(TOPIC, FMT)
assert sorted(betroffen) == ["Alpha", "Beta"]
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "pruefer"
assert cards["alpha"]["md"] # Text bleibt — der Prüfer arbeitet auf dem Bestand
assert cards["gamma"]["stage"] == "done"

View File

@@ -135,6 +135,16 @@ export async function resetGuideBoard(topic, format, abStage) {
return jsonOrThrow(res)
}
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
export async function repairGuideBoard(topic, format) {
const res = await fetch(`${BASE}/guides/board/repair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, ab_stage: 0 }),
})
return jsonOrThrow(res)
}
export async function cancelBlocks(topic) {
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
}

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchGuideBoard } from '../api.js'
import { fetchGuideBoard, repairGuideBoard } from '../api.js'
import KanbanBoard from './KanbanBoard.vue'
const props = defineProps({
@@ -32,7 +32,7 @@ const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0))
const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0)
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
const STAGES = ['lernziele', 'zuweisung', 'writer', 'pruefer', 'fix']
const sel = ref(null)
const selCard = ref(null)
const confirm = ref(null)
@@ -63,6 +63,23 @@ function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
const repairBusy = ref(false)
const repairInfo = ref('')
async function repairClick() {
repairBusy.value = true
repairInfo.value = ''
try {
const r = await repairGuideBoard(props.topic, props.format)
repairInfo.value = (r.betroffen || []).length
? `${r.betroffen.length} Abschnitt(e) → Prüfen`
: 'keine Befunde im letzten QA-Report'
if ((r.betroffen || []).length) startPoll()
} catch (e) {
repairInfo.value = String(e.message || e)
} finally {
repairBusy.value = false
}
}
function restartHere() {
const s = sel.value
sel.value = null
@@ -93,6 +110,9 @@ function resetHere() {
<template v-else>
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total && board?.qa_guide != null && board.qa_guide < 10" class="gb-act"
:disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span>
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
</template>
</div>