Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
14 KiB
JSON

{"repo": "aitema-de/edufunds", "files": {"scripts/wizard-e2e-probe.mjs": {"b1e3f465f6d6ed9a778316f0e7922fc2b9dcf945": {"t": 1786067729.284653, "n": 13746, "h": "0731709250155a26", "c": "/**\n * wizard-e2e-probe.mjs — Autonomer End-zu-Ende-Qualitaetstest des Antragswizards.\n *\n * Zweck: Pruefen, wie gut ein Antrag wird, wenn der Antragsteller nur SEHR\n * spaerliche, vage Infos liefert. Pro fiktiver Schule + knapper Idee laeuft der\n * KOMPLETTE Prozess: Matching -> Wizard-Start -> adaptives Interview (beantwortet\n * von einer \"ueberlastete Lehrkraft\"-LLM-Persona, die knapp/unpraezise antwortet)\n * -> Antragsgenerierung inkl. Finanzplan. Alle Zwischenschritte werden erfasst.\n *\n * Laeuft gegen den isolierten Dev-Server auf :3190 (DB = edufunds_test).\n * Persona-Antworten via DeepSeek direkt (Key aus .env.local).\n *\n * Output: .planning/wizard-probe/run-NN-<programmId>.json (volles Dossier je Lauf)\n * + .planning/wizard-probe/_index.json (Uebersicht)\n */\n\nimport { readFileSync, writeFileSync, mkdirSync, existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nconst REPO = \"/home/kolja/edufunds-app\";\nconst BASE = process.env.PROBE_BASE || \"http://localhost:3190\";\nconst OUT = join(REPO, \".planning\", \"wizard-probe\");\nconst CONCURRENCY = Number(process.env.PROBE_CONCURRENCY || 3);\n\n// --- DeepSeek-Key aus .env.local lesen ---\nfunction readEnvLocal() {\n const txt = readFileSync(join(REPO, \".env.local\"), \"utf8\");\n const env = {};\n for (const line of txt.split(\"\\n\")) {\n const m = line.match(/^\\s*([A-Z0-9_]+)\\s*=\\s*(.*)\\s*$/);\n if (m) env[m[1]] = m[2].replace(/^[\"']|[\"']$/g, \"\");\n }\n return env;\n}\nconst ENV = readEnvLocal();\nconst DEEPSEEK_KEY = ENV.DEEPSEEK_API_KEY;\nif (!DEEPSEEK_KEY) {\n console.error(\"FEHLER: DEEPSEEK_API_KEY nicht in .env.local gefunden\");\n process.exit(1);\n}\n\nconst sleep = (ms) => new Promise((r) => setTimeout(r, ms));\n\n// --- Die 10 fiktiven Schulen + bewusst KNAPP/VAGE formulierte Ideen ---\nconst CASES = [\n { nr: 1, schule: { name: \"Grundschule am Lindenpark\", typ: \"Grundschule\", bundesland: \"DE-BE\" }, idee: \"Wir wollen was mit Tablets machen.\" },\n { nr: 2, schule: { name: \"Astrid-Lindgren-Grundschule\", typ: \"Grundschule\", bundesland: \"DE-NI\" }, idee: \"Den Schulhof schoener machen.\" },\n { nr: 3, schule: { name: \"Grundschule Sonnenblume\", typ: \"Grundschule\", bundesland: \"DE-BY\" }, idee: \"Mehr Bewegung fuer die Kinder.\" },\n { nr: 4, schule: { name: \"Grundschule Buchenweg\", typ: \"Grundschule\", bundesland: \"DE-NW\" }, idee: \"Lesen foerdern, viele koennen schlecht lesen.\" },\n { nr: 5, schule: { name: \"Grundschule Am Muehlbach\", typ: \"Grundschule\", bundesland: \"DE-BW\" }, idee: \"Irgendwas gegen Mobbing.\" },\n { nr: 6, schule: { name: \"Pestalozzi-Grundschule\", typ: \"Grundschule\", bundesland: \"DE-SN\" }, idee: \"Wir braeuchten neue Musikinstrumente.\" },\n { nr: 7, schule: { name: \"Grundschule Regenbogen\", typ: \"Grundschule\", bundesland: \"DE-HE\" }, idee: \"Einen Garten anlegen mit den Kindern.\" },\n { nr: 8, schule: { name: \"Grundschule Kleeblatt\", typ: \"Grundschule\", bundesland: \"DE-SH\" }, idee: \"Kinder sollen programmieren lernen oder so.\" },\n { nr: 9, schule: { name: \"Grundschule Am Wald\", typ: \"Grundschule\", bundesland: \"DE-BB\" }, idee: \"Viele Kinder sprechen kaum Deutsch, da muessen wir was tun.\" },\n { nr: 10, schule: { name: \"Janusz-Korczak-Grundschule\", typ: \"Grundschule\", bundesland: \"DE-TH\" }, idee: \"Gesundes Essen zum Thema machen.\" },\n];\n\n// --- HTTP-Helfer gegen den Wizard-Server ---\nasync function api(path, body, timeoutMs = 60000) {\n const ctrl = new AbortController();\n const t = setTimeout(() => ctrl.abort(), timeoutMs);\n try {\n const res = await fetch(BASE + path, {\n method: body === undefined ? \"GET\" : \"POST\",\n // x-forwarded-for=127.0.0.1 triggert den Dev-Bypass des Rate-Limiters\n // (lib/rate-limit.ts: NODE_ENV=development + 127.0.0.1 -> nicht limitiert).\n // Unter WSL kommt localhost sonst als ::ffff:127.0.0.1 an und wird limitiert.\n headers: { \"Content-Type\": \"application/json\", \"x-forwarded-for\": \"127.0.0.1\" },\n body: body === undefined ? undefined : JSON.stringify(body),\n signal: ctrl.signal,\n });\n const json = await res.json().catch(() => ({}));\n return { status: res.status, json };\n } finally {\n clearTimeout(t);\n }\n}\n\n// --- Persona: ueberlastete Lehrkraft, antwortet knapp & vage ---\nasync function personaAnswer({ idee, schule, frage, gestellteFragen, istKlaerung }) {\n const klaerungsRegel = istKlaerung\n ? `\\n\\nDIES IST EINE SCHWERPUNKT-/KLAERUNGSFRAGE VOR DEM EIGENTLICHEN INTERVIEW. Entscheide dich hier KNAPP fuer GENAU DAS Themenfeld, das deinem Anliegen am naechsten liegt (z. B. bei \"Mehr Bewegung\" → \"Sport/Bewegung\"; bei \"gesundes Essen\" → \"Ernaehrung/gesunde Schulverpflegung\"). Erfinde KEIN neues Thema, waehle das naechstliegende. 1 kurzer Satz.`\n : \"\";\n const sys = `Du bist die gestresste Schulleitung einer kleinen Grundschule und hast kaum Zeit. Eine Foerdermittel-Plattform stellt dir Interview-Fragen zu einem Projekt.\nDein urspruengliches Anliegen, knapp formuliert: \"${idee}\".\nSchule: ${schule.name} (${schule.typ}).\n\nAntworte auf JEDE Frage bewusst KNAPP und UNPRAEZISE, so wie eine ueberlastete Lehrkraft, die sich noch keine Gedanken gemacht hat:\n- Maximal 1 kurzer Satz (oft nur ein Halbsatz).\n- KAUM konkrete Zahlen. Wenn nach Betraegen/Mengen/Terminen gefragt wird, weiche aus (\"weiss ich nicht genau\", \"muessten wir noch ueberlegen\", \"so ungefaehr halt\").\n- ERFINDE KEINE Details, Partner, Studien oder Zahlen. Lieber vage bleiben als ausschmuecken.\n- Bleib beim urspruenglichen Anliegen, schweife nicht in fremde Themen ab.\n- Keine Hoeflichkeitsfloskeln, keine Rueckfragen. Nur die knappe Antwort.${klaerungsRegel}`;\n const usr = `Frage der Plattform: \"${frage}\"\n\nBisher schon gefragt: ${gestellteFragen.length ? gestellteFragen.map((q) => `\"${q}\"`).join(\"; \") : \"(noch nichts)\"}\n\nDeine knappe, vage Antwort:`;\n\n for (let attempt = 0; attempt < 3; attempt++) {\n try {\n const res = await fetch(\"https://api.deepseek.com/v1/chat/completions\", {\n method: \"POST\",\n headers: { Authorization: `Bearer ${DEEPSEEK_KEY}`, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n model: \"deepseek-chat\",\n messages: [{ role: \"system\", content: sys }, { role: \"user\", content: usr }],\n temperature: 0.6,\n max_tokens: 80,\n }),\n });\n const j = await res.json();\n let a = j?.choices?.[0]?.message?.content?.trim();\n if (a) {\n a = a.replace(/^[\"'\\s]+|[\"'\\s]+$/g, \"\");\n if (a.length > 0) return a;\n }\n } catch (e) {\n // retry\n }\n await sleep(1500);\n }\n // Fallback, damit der Wizard nicht mit leerer Antwort blockiert\n return \"Weiss ich noch nicht genau, muessten wir ueberlegen.\";\n}\n\n// --- Ein kompletter Antragsdurchlauf ---\nasync function runCase(c) {\n const log = (m) => console.log(`[Fall ${String(c.nr).padStart(2, \"0\")}] ${m}`);\n const dossier = {\n nr: c.nr,\n schule: c.schule,\n idee: c.idee,\n startedAt: new Date().toISOString(),\n match: null,\n matchClarification: null,\n gewaehltesProgramm: null,\n sessionToken: null,\n transcript: [], // [{ runde, frage, rationale, antwort, factsNach }]\n finalState: null,\n finishedAt: null,\n error: null,\n };\n\n try {\n // 1) MATCHING (knappe Idee rein). Klaerungsfragen des Matchers werden\n // beantwortet (statt mit forceRanking ueberfahren) — so wirkt die\n // Klaerungs-Staerke des Matchers bei vagem Input. forceRanking erst als\n // letzte Instanz nach 2 Klaerungsrunden.\n log(`Matching: \"${c.idee}\"`);\n let anliegen = c.idee;\n let match = await api(\"/api/match\", {\n anliegen,\n schulname: c.schule.name,\n schultyp: c.schule.typ,\n bundesland: c.schule.bundesland,\n }, 90000);\n\n const klaerungen = [];\n for (let kr = 0; kr < 2 && match.json?.kind === \"clarification\"; kr++) {\n klaerungen.push(match.json.question);\n const klarAntwort = await personaAnswer({\n idee: c.idee, schule: c.schule, frage: match.json.question, gestellteFragen: [], istKlaerung: true,\n });\n log(`Match-Klaerung beantwortet: \"${klarAntwort}\"`);\n anliegen = `${anliegen} — ${klarAntwort}`;\n // forceRanking IMMER beim Re-Match: die beantwortete Klaerung schaerft das\n // Anliegen, aber der Matcher MUSS jetzt ranken (sonst kann er eine leere\n // Liste zurueckgeben -> Fall faellt ganz aus). forceRanking unterdrueckt\n // zudem eine erneute CLARIFY, sodass die Schleife nach 1 Runde endet.\n match = await api(\"/api/match\", {\n anliegen,\n schulname: c.schule.name, schultyp: c.schule.typ, bundesland: c.schule.bundesland,\n previousAnliegen: c.idee, forceRanking: true,\n }, 90000);\n }\n if (klaerungen.length) dossier.matchClarification = klaerungen.length === 1 ? klaerungen[0] : klaerungen;\n dossier.match = match.json;\n const top = match.json?.matches?.[0];\n if (!top) throw new Error(`Kein Match (status ${match.status}): ${JSON.stringify(match.json).slice(0, 200)}`);\n dossier.gewaehltesProgramm = { id: top.id, name: top.programm?.name, score: top.score, passt_weil: top.passt_weil };\n log(`Top-Treffer: ${top.programm?.name} (Score ${top.score})`);\n\n // 2) WIZARD-START\n const start = await api(\"/api/wizard/start\", {\n programmId: top.id,\n seedFacts: { schule: { name: c.schule.name, typ: c.schule.typ, bundesland: c.schule.bundesland } },\n }, 90000);\n if (start.status !== 200 || !start.json?.sessionToken) {\n throw new Error(`Start fehlgeschlagen (status ${start.status}): ${JSON.stringify(start.json).slice(0, 200)}`);\n }\n const token = start.json.sessionToken;\n dossier.sessionToken = token;\n let phase = start.json.phase;\n let frage = start.json.question?.content || null;\n let rationale = start.json.question?.rationale || null;\n const gestellteFragen = [];\n log(`Interview gestartet (Programm-Richtlinie: ${start.json.richtlinieStatus?.available ? \"vorhanden\" : \"fehlt\"})`);\n\n // 3) INTERVIEW-SCHLEIFE\n let runde = 0;\n const MAX_TURNS = 20;\n while (phase === \"interviewing\" && frage && runde < MAX_TURNS) {\n runde++;\n gestellteFragen.push(frage);\n const antwort = await personaAnswer({ idee: c.idee, schule: c.schule, frage, gestellteFragen });\n log(`F${runde}: ${frage.slice(0, 60)}... -> A: ${antwort.slice(0, 50)}`);\n\n // Antwort senden, 503=retrybar\n let ans;\n for (let retry = 0; retry < 3; retry++) {\n ans = await api(\"/api/wizard/answer\", { sessionToken: token, answer: antwort }, 90000);\n if (ans.status === 503 && ans.json?.retryable) { await sleep(2000); continue; }\n break;\n }\n if (ans.status !== 200) throw new Error(`answer fehlgeschlagen (status ${ans.status}): ${JSON.stringify(ans.json).slice(0, 200)}`);\n\n dossier.transcript.push({ runde, frage, rationale, antwort, factsNach: ans.json.facts });\n phase = ans.json.phase;\n frage = ans.json.question?.content || null;\n rationale = ans.json.question?.rationale || null;\n }\n log(`Interview fertig nach ${runde} Fragen, Phase=${phase}`);\n\n // 4) GENERIERUNG (synchron, bis 5 Min)\n log(\"Generiere Antrag (Pipeline laeuft, bis ~2-3 Min)...\");\n const gen = await api(\"/api/wizard/generate\", { sessionToken: token }, 300000);\n if (gen.status !== 200) {\n // evtl. lief sie schon -> pollen\n log(`generate status ${gen.status}: ${JSON.stringify(gen.json).slice(0, 150)} — polle State`);\n }\n\n // 5) KANONISCHER VOLLSTATE\n const state = await api(`/api/wizard/${token}`, undefined, 60000);\n dossier.finalState = state.json;\n const g = state.json?.generation;\n log(`FERTIG. Abschnitte: ${g?.sections?.length ?? 0}, Finanzplan-Posten: ${g?.finanzplan?.posten?.length ?? 0}, finalText: ${g?.finalText ? g.finalText.length + \" Zeichen\" : \"FEHLT\"}, Tokens: ${state.json?.costs?.usedTokens ?? \"?\"}`);\n } catch (e) {\n dossier.error = String(e?.message || e);\n console.error(`[Fall ${c.nr}] FEHLER: ${dossier.error}`);\n }\n\n dossier.finishedAt = new Date().toISOString();\n if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true });\n const fname = `run-${String(c.nr).padStart(2, \"0\")}-${dossier.gewaehltesProgramm?.id || \"nomatch\"}.json`;\n writeFileSync(join(OUT, fname), JSON.stringify(dossier, null, 2), \"utf8\");\n return { nr: c.nr, file: fname, programm: dossier.gewaehltesProgramm?.name || null, fragen: dossier.transcript.length, error: dossier.error, finalTextLen: dossier.finalState?.generation?.finalText?.length || 0 };\n}\n\n// --- Pool-Runner mit begrenzter Parallelitaet ---\nasync function main() {\n if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true });\n const only = (process.env.PROBE_ONLY || \"\").split(\",\").map((s) => Number(s.trim())).filter(Boolean);\n const cases = only.length ? CASES.filter((c) => only.includes(c.nr)) : CASES;\n console.log(`Starte ${cases.length} E2E-Laeufe gegen ${BASE} (Parallelitaet ${CONCURRENCY})${only.length ? \" [nur \" + only.join(\",\") + \"]\" : \"\"}`);\n const results = [];\n let idx = 0;\n async function worker() {\n while (idx < cases.length) {\n const c = cases[idx++];\n const r = await runCase(c);\n results.push(r);\n }\n }\n await Promise.all(Array.from({ length: Math.min(CONCURRENCY, cases.length) }, worker));\n results.sort((a, b) => a.nr - b.nr);\n writeFileSync(join(OUT, \"_index.json\"), JSON.stringify({ generatedAt: new Date().toISOString(), base: BASE, results }, null, 2), \"utf8\");\n console.log(\"\\n=== ZUSAMMENFASSUNG ===\");\n for (const r of results) {\n console.log(`Fall ${String(r.nr).padStart(2, \"0\")}: ${r.error ? \"FEHLER \" + r.error : `${r.programm} · ${r.fragen} Fragen · Antrag ${r.finalTextLen} Zeichen`}`);\n }\n console.log(`\\nDossiers in ${OUT}`);\n}\n\nmain().catch((e) => { console.error(e); process.exit(1); });\n"}}}}