This commit is contained in:
team3
2026-06-21 13:27:19 +02:00
parent ba529e61a8
commit 20831e1399
14 changed files with 486 additions and 55 deletions

View File

@@ -233,11 +233,12 @@ async function handleResetBausteine() {
await loadBausteine()
}
async function handleBausteineClick({ instructions }) {
async function handleBausteineClick({ instructions, abPhase = null }) {
if (!selectedTopic.value) return
uiError.value = null
try {
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
// Quelle ist hier schon festgeschrieben; nur abPhase steuert den Re-Run-Umfang.
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase)
} catch (e) {
uiError.value = e.message
return

View File

@@ -42,11 +42,11 @@ export async function fetchBausteineStatus(topic) {
return res.json()
}
export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '') {
export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null) {
const res = await fetch(`${BASE}/bausteine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt }),
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt, ab_phase: abPhase }),
})
return jsonOrThrow(res)
}
@@ -78,16 +78,21 @@ export async function chatBaustein({ topic, baustein, section, section_kompakt =
export async function pruefeBaustein({
topic, baustein, section, section_kompakt = '', provider,
aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [],
nachgefragt = false, begruendung = '', tier2 = false, tier3 = false, messages = [], gruendlich = false,
nachgefragt = false, begruendung = '', muster = '', tier2 = false, tier3 = false, messages = [], gruendlich = false,
}) {
const res = await fetch(`${BASE}/bausteine/pruefung`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, tier2, tier3, messages, provider, gruendlich }),
body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, muster, tier2, tier3, messages, provider, gruendlich }),
})
return jsonOrThrow(res)
}
export async function fetchFrageMuster(topic, baustein) {
const res = await fetch(`${BASE}/bausteine/frage-muster?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}`)
return jsonOrThrow(res)
}
export async function fetchTopicFortschritt(topic) {
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
return res.json()

View File

@@ -1,6 +1,6 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { chatBaustein, pruefeBaustein } from '../api.js'
import { chatBaustein, pruefeBaustein, fetchFrageMuster } from '../api.js'
import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown } from '../markdown.js'
import { useChat, istUnten } from '../composables/useChat.js'
@@ -53,6 +53,9 @@ const pruefMessages = slot.messages // {role, kind: 'frage'|'nachfrage'|'antwort
const pruefPhase = slot.phase
const aktuelleFrage = slot.aktuelleFrage // ankert Bewertung/Diskussion
const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion über eine Bewertung
const musterQuelle = slot.musterQuelle // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
const musterPool = slot.musterPool // Arbeitskopie, ohne Zurücklegen; leer → Reset
const musterGeladen = slot.musterGeladen
// Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner.
const pruefInput = ref('')
const pruefLoading = ref(false)
@@ -124,36 +127,76 @@ function gestellteFragen() {
return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content)))
}
// --- Fragen-Pool: ZIEL vorgenerierte, geprüfte, unbeantwortete Fragen je Baustein (im Slot) ---
// --- Fragen-Pool: ZIEL vorformulierte, unbeantwortete Fragen je Baustein (im Slot) ---
const POOL_ZIEL = 5
const pool = slot.pool
const inflight = slot.inflight
let poolMiss = 0 // aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife (kleine Bausteine)
let poolMiss = 0 // nur Fallback: aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife
// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback auf Live-Generierung.
const musterModus = computed(() => musterGeladen.value && musterQuelle.value.length > 0)
function istBekannt(text) {
const n = normFrage(text)
return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(f) === n)
}
// Schon gestellte + bereits vorgemerkte Fragen — der Generator/Kritiker soll sie
// (auch sinngemäß) meiden. Original-Texte, nicht normalisiert.
// Schon gestellte + bereits vorgemerkte Fragen — der Generator soll sie (auch
// sinngemäß) meiden. Nur Fallback-Modus. Original-Texte, nicht normalisiert.
function vermeideListe() {
const gestellt = pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => m.content)
return [...gestellt, ...pool.value]
}
// Muster-Sidecar einmal je Baustein laden (Slot-cached). Leer → Fallback.
async function musterLaden() {
if (musterGeladen.value) return
try {
const res = await fetchFrageMuster(props.topic, props.baustein)
musterQuelle.value = (res.muster || []).map((m) => (m.frage || '').trim()).filter(Boolean)
} catch {
musterQuelle.value = []
}
musterGeladen.value = true
}
function mischen(arr) { // Fisher-Yates
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
// Ein Muster ohne Zurücklegen ziehen; Pool leer → Reset (neu mischen). null = Fallback.
function musterNehmen() {
if (!musterQuelle.value.length) return null
if (!musterPool.value.length) musterPool.value = mischen(musterQuelle.value)
return musterPool.value.shift()
}
function frageEinzeln() {
// Ein Agent → eine Frage (Backend wählt zufälligen Typ + Subbaustein, dann Kritiker).
const muster = musterNehmen()
if (muster) {
// Muster-Modus: aus gezogener Saat eine konkrete, leicht andere Frage formulieren.
return pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'frage', muster,
}).then((res) => res.frage)
}
// Fallback (kein Muster-Sidecar): Live-Generierung mit Vermeide-Liste.
return pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe(),
}).then((res) => res.frage)
}
// Pool PARALLEL auffüllen: pro fehlender Frage ein eigener Agent gleichzeitig.
// Initial 5 parallel; nach Verbrauch genau die fehlende(n) nach.
// Pool PARALLEL auffüllen: pro fehlender Frage ein eigener Agent. Im Muster-Modus
// zieht jeder ein DISTINKTES Muster (ohne Zurücklegen) → keine Kollision, kein Miss-Cap.
function poolFuellen() {
while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < 3) {
const cap = musterModus.value ? Infinity : 3
while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < cap) {
inflight.value++
frageEinzeln()
.then((text) => {
@@ -168,9 +211,10 @@ function poolFuellen() {
// Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten.
async function poolNehmen() {
if (!pool.value.length) {
const cap = musterModus.value ? Infinity : 3
pruefLoading.value = true
try {
while (!pool.value.length && (inflight.value > 0 || poolMiss < 3)) {
while (!pool.value.length && (inflight.value > 0 || poolMiss < cap)) {
if (!inflight.value) poolFuellen()
await new Promise((r) => setTimeout(r, 120))
}
@@ -179,10 +223,11 @@ async function poolNehmen() {
return pool.value.length ? pool.value.shift() : null
}
// Erste Frage (Phase idle): ZIEL parallel anstoßen, schnellste zuerst zeigen.
// Erste Frage (Phase idle): Muster laden, ZIEL parallel anstoßen, schnellste zuerst zeigen.
async function frageAnfordern() {
if (pruefLoading.value) return
poolMiss = 0
await musterLaden()
poolFuellen()
const text = await poolNehmen()
if (text) {
@@ -197,6 +242,7 @@ async function frageAnfordern() {
// „Nächste Frage": sofort aus dem Pool, dann nachfüllen.
async function naechsteFrageZeigen() {
if (pruefLoading.value) return
await musterLaden()
const text = await poolNehmen()
if (text) {
frageZeigen(text)

View File

@@ -67,6 +67,17 @@ const bausteineState = computed(() => {
return props.bausteine.ready ? 'done' : 'none'
})
// Re-Run ab gewählter Phase (1-basiert). null = ab Anfang / Fortsetzen.
const gewaehltePhase = ref(null)
// Phasen anklickbar, sobald etwas (teil-)gebaut ist und gerade nicht generiert wird.
const phasenWaehlbar = computed(() => (props.bausteine.ready || props.bausteine.partial) && !props.bausteine.generating)
const gewaehltesLabel = computed(() => (props.bausteine.steps || [])[(gewaehltePhase.value || 0) - 1]?.label || '')
function phaseKlick(n) {
if (!phasenWaehlbar.value) return
gewaehltePhase.value = gewaehltePhase.value === n ? null : n
}
// Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile
const activeGenerations = computed(() => {
const bausteinLines = props.activeBausteine
@@ -90,7 +101,10 @@ function confirmResetBausteine() {
function handleBausteinePlay() {
if (bausteineState.value === 'generating') return
emit('bausteineClick', { instructions: '' })
// Nur „Neu generieren" (ready) nutzt eine Phase; Fortsetzen/Erstbau resumen ohne Löschen.
const abPhase = props.bausteine.ready ? (gewaehltePhase.value ?? 1) : null
emit('bausteineClick', { instructions: '', abPhase })
gewaehltePhase.value = null
}
// Name-Klick = Primäraktion: fertige Bausteine → Übersicht, sonst Panel auf/zu.
@@ -361,12 +375,13 @@ function saveQuelle() {
>Pausiert</span>
<span class="step-dots">
<span
v-for="s in (bausteine.steps || [])"
v-for="(s, i) in (bausteine.steps || [])"
:key="s.label"
class="step-dot"
:class="s.state"
:title="s.state === 'active' ? (bausteine.progress || s.label) : s.label"
></span>
class="step-pill"
:class="[s.state, { sel: gewaehltePhase === i + 1, klickbar: phasenWaehlbar }]"
:title="(s.state === 'active' ? (bausteine.progress || s.label) : s.label) + (phasenWaehlbar ? ' — Klick: ab hier neu' : '')"
@click.stop="phaseKlick(i + 1)"
>{{ i + 1 }}</span>
</span>
</button>
<button class="panel-toggle" :class="{ open: isOpen('bausteine') }" title="Aktionen" @click.stop="togglePanel('bausteine')"></button>
@@ -376,7 +391,7 @@ function saveQuelle() {
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'bausteine' }" @click="confirmCancelBausteine">{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Abbrechen' }}</button>
</template>
<template v-else>
<button class="panel-btn play" @click="handleBausteinePlay">{{ bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Neu generieren' : 'Generieren' }}</button>
<button class="panel-btn play" @click="handleBausteinePlay">{{ bausteine.partial ? 'Fortsetzen' : bausteine.ready ? (gewaehltePhase ? `Ab «${gewaehltesLabel}» neu` : 'Neu generieren') : 'Generieren' }}</button>
<button
v-if="bausteine.ready || bausteine.partial"
class="panel-btn danger"
@@ -717,27 +732,45 @@ function saveQuelle() {
.step-dots {
display: inline-flex;
gap: 4px;
gap: 5px;
flex: 1;
}
.step-dot {
width: 7px;
height: 7px;
/* Grobe Phasen als nummerierte Pillen (15) — Anzeige + anklickbar für Re-Run ab hier. */
.step-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
border-radius: 50%;
background: var(--border-strong);
color: var(--bg);
font-size: 0.62rem;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
border: 1.5px solid transparent;
}
.step-dot.done {
.step-pill.done {
background: var(--success-border);
}
.step-dot.active {
.step-pill.active {
background: var(--warning-border);
animation: dot-pulse 1.2s ease-in-out infinite;
}
.step-pill.klickbar {
cursor: pointer;
}
.step-pill.sel {
border-color: var(--text);
box-shadow: 0 0 0 1px var(--text);
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }

View File

@@ -15,8 +15,11 @@ export function usePruefSlot(key) {
phase: ref('idle'),
aktuelleFrage: ref(''),
letztesFeedback: ref(''),
pool: ref([]), // vorgenerierte, geprüfte, unbeantwortete Fragen
inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert)
pool: ref([]), // vorformulierte, unbeantwortete Fragen
inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert)
musterQuelle: ref([]), // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
musterPool: ref([]), // Arbeitskopie: gezogene Saat ohne Zurücklegen; leer → Reset
musterGeladen: ref(false), // Muster-Sidecar schon abgerufen?
})
}
return store.get(key)