Verbesserung für Tests von Prompts #80

Merged
Lars merged 2 commits from develop into main 2026-04-12 11:58:07 +02:00
4 changed files with 492 additions and 39 deletions

View File

@ -278,7 +278,11 @@ async def execute_base_prompt(
if enable_debug:
debug_info['template'] = template
debug_info['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '')
# Volltext für Test-UI (Admin); sehr große Prompts nur weich begrenzen
_max = 512 * 1024
debug_info['final_prompt'] = (
prompt_text if len(prompt_text) <= _max else prompt_text[:_max] + "\n… [gekürzt, >512KB]"
)
debug_info['available_variables'] = list(variables.keys())
# Call AI
@ -397,7 +401,10 @@ async def execute_pipeline_prompt(
if enable_debug:
prompt_debug['source'] = 'inline'
prompt_debug['template'] = template
prompt_debug['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '')
_max = 512 * 1024
prompt_debug['final_prompt'] = (
prompt_text if len(prompt_text) <= _max else prompt_text[:_max] + "\n… [gekürzt, >512KB]"
)
prompt_debug.update(placeholder_debug)
response = await openrouter_call_func(prompt_text)

View File

@ -937,27 +937,60 @@ def export_placeholder_catalog_zip(
# ── KI-Assisted Prompt Engineering ───────────────────────────────────────────
async def call_openrouter(prompt: str, max_tokens: int = 1500) -> str:
async def call_openrouter(prompt: str, max_tokens: int = 4096) -> str:
"""Call OpenRouter API to get AI response."""
if not OPENROUTER_KEY:
raise HTTPException(status_code=500, detail="OpenRouter API key not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
json={
"model": OPENROUTER_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens
},
timeout=60.0
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
json={
"model": OPENROUTER_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens
},
timeout=120.0
)
except httpx.TimeoutException as e:
raise HTTPException(status_code=504, detail=f"OpenRouter-Zeitüberschreitung: {e}") from e
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter-Verbindungsfehler: {e}") from e
if resp.status_code != 200:
raise HTTPException(
status_code=resp.status_code,
detail=f"OpenRouter API error ({resp.status_code}): {resp.text[:2000]}",
)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=f"OpenRouter API error: {resp.text}")
try:
data = resp.json()
except json.JSONDecodeError:
raise HTTPException(
status_code=502,
detail=f"OpenRouter lieferte kein JSON: {resp.text[:800]}",
)
return resp.json()['choices'][0]['message']['content'].strip()
choices = data.get("choices") or []
if not choices:
err_snip = json.dumps(data.get("error") or data, ensure_ascii=False)[:1200]
raise HTTPException(
status_code=502,
detail=f"OpenRouter ohne choices in der Antwort: {err_snip}",
)
message = choices[0].get("message") or {}
content = message.get("content")
if content is None:
raise HTTPException(
status_code=502,
detail="OpenRouter-Antwort ohne message.content (z. B. Tool-Calls oder abweichendes Schema).",
)
if not isinstance(content, str):
content = str(content)
return content.strip()
def collect_example_data(profile_id: str, data_categories: list[str]) -> dict:

View File

@ -1,7 +1,264 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useLayoutEffect } from 'react'
import { api } from '../utils/api'
import { X, Plus, Trash2, MoveUp, MoveDown, Code } from 'lucide-react'
import PlaceholderPicker from './PlaceholderPicker'
import Markdown from '../utils/Markdown'
/** Debug aus erfolgreicher Ausführung oder aus FastAPI-Fehlerdetail */
function extractExecutionDebug(testResult) {
if (!testResult) return null
const raw = testResult.debug ?? testResult
if (raw && typeof raw === 'object' && raw.detail && raw.detail.debug) {
return { ...raw.detail.debug, ...raw.detail }
}
return raw
}
/** Vollständiger Prompt-Text für die Anzeige (Basis oder Pipeline-Stufen) */
function buildPromptTextForDisplay(testResult) {
if (!testResult || testResult.error) return ''
const dbg = extractExecutionDebug(testResult)
const t = testResult.type
if (t === 'base' && dbg?.final_prompt) return dbg.final_prompt
if (t === 'pipeline' && dbg?.stages?.length) {
const parts = []
for (const st of dbg.stages) {
for (const p of st.prompts || []) {
const label =
p.source === 'reference'
? `Stage ${st.stage} · Ref: ${p.ref_slug || '?'}`
: `Stage ${st.stage} · Inline`
const text =
p.final_prompt ||
(p.ref_debug && typeof p.ref_debug === 'object' ? p.ref_debug.final_prompt : '') ||
''
if (text) parts.push(`── ${label} ──\n${text}`)
}
}
return parts.join('\n\n')
}
return dbg?.final_prompt || ''
}
function sanitizeDebugForPanel(dbg, resultType) {
if (!dbg) return {}
const o = { ...dbg }
delete o.final_prompt
delete o.template
delete o.ai_response_preview
if (o.stages && resultType === 'pipeline') {
o.stages = o.stages.map((s) => ({
stage: s.stage,
available_variables: s.available_variables,
output: s.output,
prompts: (s.prompts || []).map((p) => ({
source: p.source,
ref_slug: p.ref_slug,
output_key: p.output_key,
context_var_key: p.context_var_key,
resolved_placeholders: p.resolved_placeholders || p.ref_debug?.resolved_placeholders,
unresolved_placeholders: p.unresolved_placeholders || p.ref_debug?.unresolved_placeholders,
warnings: p.warnings || p.ref_debug?.warnings,
ai_response_length: p.ai_response_length
}))
}))
}
return o
}
/** Langer Inhalt: zunächst auf maxRem eingeklappt, „Mehr/Weniger anzeigen“ */
function ExpandableCollapsible({ children, contentKey, maxRem = 28 }) {
const [expanded, setExpanded] = useState(false)
const wrapRef = useRef(null)
const [showToggle, setShowToggle] = useState(false)
useEffect(() => {
setExpanded(false)
}, [contentKey])
useLayoutEffect(() => {
const el = wrapRef.current
if (!el) return
if (expanded) {
setShowToggle(true)
return
}
setShowToggle(el.scrollHeight > el.clientHeight + 2)
}, [contentKey, expanded])
return (
<div>
<div
ref={wrapRef}
style={{
maxHeight: expanded ? 'none' : `${maxRem}rem`,
overflow: expanded ? 'visible' : 'hidden'
}}
>
{children}
</div>
{showToggle && (
<div style={{ marginTop: 10, textAlign: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => setExpanded((e) => !e)}
>
{expanded ? 'Weniger anzeigen' : 'Mehr anzeigen'}
</button>
</div>
)}
</div>
)
}
function renderTestOutput(testResult) {
if (!testResult) return null
if (testResult.error) {
const d = testResult.debug?.detail
const msg =
typeof d === 'string'
? d
: d?.error || d?.message || testResult.error_message || 'Unbekannter Fehler'
return (
<div style={{ color: 'var(--danger)', fontSize: 14, lineHeight: 1.5 }}>
{typeof msg === 'string' ? msg : JSON.stringify(msg, null, 2)}
</div>
)
}
const out = testResult.output
const fmt = testResult.output_format
const typ = testResult.type
if (out == null) {
return <span style={{ color: 'var(--text3)' }}>Keine Ausgabe</span>
}
const proseBox = (content, contentKey) => (
<div
style={{
padding: 16,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 14,
lineHeight: 1.55,
color: 'var(--text1)',
textAlign: 'left'
}}
>
{contentKey != null ? (
<ExpandableCollapsible contentKey={contentKey}>{content}</ExpandableCollapsible>
) : (
content
)}
</div>
)
if (typ === 'pipeline' && out && typeof out === 'object' && !Array.isArray(out)) {
return (
<div style={{ display: 'grid', gap: 16 }}>
{Object.entries(out).map(([key, val]) => (
<div key={key}>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text2)',
marginBottom: 8,
textAlign: 'left'
}}
>
{key}
</div>
{typeof val === 'string' ? (
proseBox(
<Markdown text={val} />,
`pipe-${key}-${val.length}-${val.slice(0, 120)}`
)
) : (
<ExpandableCollapsible contentKey={`pipe-json-${key}-${JSON.stringify(val).slice(0, 200)}`} maxRem={22}>
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
textAlign: 'left'
}}
>
{JSON.stringify(val, null, 2)}
</pre>
</ExpandableCollapsible>
)}
</div>
))}
</div>
)
}
if (typeof out === 'string') {
if (fmt === 'json') {
try {
const parsed = JSON.parse(out)
const jsonStr = JSON.stringify(parsed, null, 2)
return (
<ExpandableCollapsible contentKey={`json-out-${jsonStr.length}-${jsonStr.slice(0, 80)}`} maxRem={26}>
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
textAlign: 'left'
}}
>
{jsonStr}
</pre>
</ExpandableCollapsible>
)
} catch {
return proseBox(
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{out}</pre>,
`json-fail-${out.length}-${out.slice(0, 80)}`
)
}
}
return proseBox(<Markdown text={out} />, `text-out-${out.length}-${out.slice(0, 120)}`)
}
if (typeof out === 'object') {
const jsonStr = JSON.stringify(out, null, 2)
return (
<ExpandableCollapsible contentKey={`obj-out-${jsonStr.length}-${jsonStr.slice(0, 120)}`} maxRem={26}>
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
textAlign: 'left'
}}
>
{jsonStr}
</pre>
</ExpandableCollapsible>
)
}
return <span>{String(out)}</span>
}
/**
* Unified Prompt Editor Modal (Issue #28 Phase 3)
@ -47,6 +304,8 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState(null)
const [showDebug, setShowDebug] = useState(false)
/** 'response' | 'prompt' | 'debug' */
const [testResultTab, setTestResultTab] = useState('response')
useEffect(() => {
loadAvailablePrompts()
@ -280,6 +539,7 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
try {
const result = await api.executeUnifiedPrompt(prompt.slug, null, null, true)
setTestResult(result)
setTestResultTab('response')
setShowDebug(true)
} catch (e) {
// Show error AND try to extract debug info from error
@ -290,7 +550,12 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
try {
const parsed = JSON.parse(errorMsg)
if (parsed.detail) {
setError('Test-Fehler: ' + parsed.detail)
const d = parsed.detail
const msg =
typeof d === 'string'
? d
: d.error || d.message || d.msg || JSON.stringify(d).slice(0, 500)
setError('Test-Fehler: ' + msg)
debugData = parsed
} else {
setError('Test-Fehler: ' + errorMsg)
@ -305,6 +570,7 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
error_message: errorMsg,
debug: debugData || { error: errorMsg }
})
setTestResultTab('response')
setShowDebug(true) // ALWAYS show debug on test, even on error
} finally {
setTesting(false)
@ -315,7 +581,12 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
if (!testResult) return
// Extract all placeholder data from test result
const debug = testResult.debug || testResult
const raw = testResult.debug || testResult
// API-Fehler: FastAPI liefert { detail: { error, debug: { resolved_placeholders, ... } } }
const debug =
raw && typeof raw === 'object' && raw.detail && raw.detail.debug
? { ...raw.detail.debug, ...raw.detail }
: raw
const exportData = {
export_date: new Date().toISOString(),
prompt_slug: prompt?.slug || 'unknown',
@ -711,7 +982,7 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
</div>
)}
{/* Debug Output */}
{/* Test-Ergebnis: Antwort · Prompt · Debug */}
{showDebug && testResult && (
<div style={{
marginTop: 16,
@ -724,12 +995,14 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12
marginBottom: 12,
flexWrap: 'wrap',
gap: 8
}}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
🔬 Debug-Info
Test-Ergebnis
</h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="btn"
onClick={handleExportPlaceholders}
@ -739,31 +1012,120 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
background: 'var(--accent)',
color: 'white'
}}
title="Exportiere alle Platzhalter mit Werten als JSON"
title="Exportiere Platzhalter / Kontext als JSON"
>
📋 Platzhalter exportieren
📋 Kontext exportieren
</button>
<button
onClick={() => setShowDebug(false)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
aria-label="Schließen"
>
<X size={16} color="var(--text3)" />
</button>
</div>
</div>
<pre style={{
fontSize: 11,
fontFamily: 'monospace',
background: 'var(--bg)',
padding: 12,
borderRadius: 6,
overflow: 'auto',
maxHeight: 400,
lineHeight: 1.5,
color: 'var(--text2)'
}}>
{JSON.stringify(testResult.debug || testResult, null, 2)}
</pre>
<div
role="tablist"
style={{
display: 'flex',
gap: 6,
marginBottom: 12,
flexWrap: 'wrap',
borderBottom: '1px solid var(--border)',
paddingBottom: 8
}}
>
{[
{ id: 'response', label: 'Antwort' },
{ id: 'prompt', label: 'Prompt (an LLM)' },
{ id: 'debug', label: 'Debug' }
].map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={testResultTab === tab.id}
onClick={() => setTestResultTab(tab.id)}
style={{
padding: '6px 12px',
fontSize: 12,
borderRadius: 6,
border: '1px solid var(--border)',
background: testResultTab === tab.id ? 'var(--accent)' : 'var(--surface)',
color: testResultTab === tab.id ? '#fff' : 'var(--text2)',
cursor: 'pointer',
fontWeight: testResultTab === tab.id ? 600 : 400
}}
>
{tab.label}
</button>
))}
</div>
<div
role="tabpanel"
style={{
minHeight: 120,
maxHeight: testResultTab === 'prompt' ? 480 : 420,
overflow: 'auto',
textAlign: 'left'
}}
>
{testResultTab === 'response' && (
<div style={{ paddingRight: 4 }}>
{renderTestOutput(testResult)}
</div>
)}
{testResultTab === 'prompt' && (
<pre
style={{
margin: 0,
fontSize: 12,
fontFamily: 'ui-monospace, monospace',
lineHeight: 1.45,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--text1)',
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)'
}}
>
{buildPromptTextForDisplay(testResult) || '(Kein Prompt-Text in der Antwort — evtl. Fehler vor Ausführung.)'}
</pre>
)}
{testResultTab === 'debug' && (
<pre
style={{
margin: 0,
fontSize: 11,
fontFamily: 'ui-monospace, monospace',
lineHeight: 1.45,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
color: 'var(--text2)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{JSON.stringify(
sanitizeDebugForPanel(
extractExecutionDebug(testResult),
testResult.error ? null : testResult.type
),
null,
2
)}
</pre>
)}
</div>
</div>
)}

View File

@ -1,5 +1,5 @@
// Lightweight Markdown renderer handles the subset used by the AI:
// ## Headings, **bold**, bullet lists, numbered lists, line breaks
// ## Headings, **bold**, bullet lists, numbered lists, fenced ``` code ```, line breaks
export default function Markdown({ text }) {
if (!text) return null
@ -7,6 +7,19 @@ export default function Markdown({ text }) {
const lines = text.split('\n')
const elements = []
let i = 0
let blockId = 0
const codeBlockStyle = {
margin: '10px 0',
padding: 12,
background: 'var(--surface2)',
borderRadius: 8,
border: '1px solid var(--border)',
overflow: 'auto',
fontSize: 12,
lineHeight: 1.5,
fontFamily: 'ui-monospace, Consolas, monospace'
}
const parseLine = (line) => {
// Parse inline **bold** and *italic*
@ -39,6 +52,44 @@ export default function Markdown({ text }) {
while (i < lines.length) {
const line = lines[i]
// Fenced code block: ``` or ```lang
const trimmedStart = line.trimStart()
if (trimmedStart.startsWith('```')) {
const lang = trimmedStart.slice(3).trim() || null
i++
const codeLines = []
while (i < lines.length) {
if (lines[i].trim().startsWith('```')) {
i++
break
}
codeLines.push(lines[i])
i++
}
const code = codeLines.join('\n')
elements.push(
<div key={`code-${blockId++}`} style={{ margin: '10px 0' }}>
{lang && (
<div
style={{
fontSize: 10,
color: 'var(--text3)',
marginBottom: 6,
fontFamily: 'ui-monospace, monospace',
letterSpacing: 0.02
}}
>
{lang}
</div>
)}
<pre style={codeBlockStyle}>
<code style={{ color: 'var(--text1)', whiteSpace: 'pre', display: 'block' }}>{code}</code>
</pre>
</div>
)
continue
}
// Skip empty lines (add spacing)
if (line.trim() === '') {
elements.push(<div key={i} style={{ height: 8 }} />)