Compare commits

...

3 Commits

Author SHA1 Message Date
25c4ecfd48 Merge pull request 'Verbesserung für Tests von Prompts' (#80) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m2s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 15s
Reviewed-on: #80
2026-04-12 11:58:07 +02:00
08c9cccdcc feat: Add expandable collapsible component for improved content display
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced `ExpandableCollapsible` component to manage the visibility of lengthy content, allowing users to toggle between expanded and collapsed views.
- Updated `renderTestOutput` to utilize the new component for displaying test results, JSON outputs, and object representations, enhancing user experience by reducing clutter.
- Enhanced `Markdown` component to support fenced code blocks, improving the rendering of code snippets with language labels and better styling.

These changes improve the readability and organization of content within the application, providing users with a more interactive and manageable interface.
2026-04-12 11:10:39 +02:00
4b6e1bed11 feat: Enhance OpenRouter API interaction and error handling
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Increased the maximum token limit in the `call_openrouter` function from 1500 to 4096 to allow for more extensive responses.
- Implemented robust error handling for API requests, including timeout and request errors, with detailed HTTP exceptions for better debugging.
- Improved JSON response handling to ensure valid data is returned, with specific error messages for missing content in the response.
- Enhanced the overall reliability of the OpenRouter API integration, providing clearer feedback for users in case of issues.

These changes improve the user experience by ensuring more comprehensive responses and clearer error reporting during API interactions.
2026-04-12 11:03: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: if enable_debug:
debug_info['template'] = template 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()) debug_info['available_variables'] = list(variables.keys())
# Call AI # Call AI
@ -397,7 +401,10 @@ async def execute_pipeline_prompt(
if enable_debug: if enable_debug:
prompt_debug['source'] = 'inline' prompt_debug['source'] = 'inline'
prompt_debug['template'] = template 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) prompt_debug.update(placeholder_debug)
response = await openrouter_call_func(prompt_text) response = await openrouter_call_func(prompt_text)

View File

@ -937,27 +937,60 @@ def export_placeholder_catalog_zip(
# ── KI-Assisted Prompt Engineering ─────────────────────────────────────────── # ── 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.""" """Call OpenRouter API to get AI response."""
if not OPENROUTER_KEY: if not OPENROUTER_KEY:
raise HTTPException(status_code=500, detail="OpenRouter API key not configured") raise HTTPException(status_code=500, detail="OpenRouter API key not configured")
async with httpx.AsyncClient() as client: try:
resp = await client.post( async with httpx.AsyncClient() as client:
"https://openrouter.ai/api/v1/chat/completions", resp = await client.post(
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"}, "https://openrouter.ai/api/v1/chat/completions",
json={ headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
"model": OPENROUTER_MODEL, json={
"messages": [{"role": "user", "content": prompt}], "model": OPENROUTER_MODEL,
"max_tokens": max_tokens "messages": [{"role": "user", "content": prompt}],
}, "max_tokens": max_tokens
timeout=60.0 },
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: try:
raise HTTPException(status_code=resp.status_code, detail=f"OpenRouter API error: {resp.text}") 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: 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 { api } from '../utils/api'
import { X, Plus, Trash2, MoveUp, MoveDown, Code } from 'lucide-react' import { X, Plus, Trash2, MoveUp, MoveDown, Code } from 'lucide-react'
import PlaceholderPicker from './PlaceholderPicker' 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) * 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 [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState(null) const [testResult, setTestResult] = useState(null)
const [showDebug, setShowDebug] = useState(false) const [showDebug, setShowDebug] = useState(false)
/** 'response' | 'prompt' | 'debug' */
const [testResultTab, setTestResultTab] = useState('response')
useEffect(() => { useEffect(() => {
loadAvailablePrompts() loadAvailablePrompts()
@ -280,6 +539,7 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
try { try {
const result = await api.executeUnifiedPrompt(prompt.slug, null, null, true) const result = await api.executeUnifiedPrompt(prompt.slug, null, null, true)
setTestResult(result) setTestResult(result)
setTestResultTab('response')
setShowDebug(true) setShowDebug(true)
} catch (e) { } catch (e) {
// Show error AND try to extract debug info from error // Show error AND try to extract debug info from error
@ -290,7 +550,12 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
try { try {
const parsed = JSON.parse(errorMsg) const parsed = JSON.parse(errorMsg)
if (parsed.detail) { 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 debugData = parsed
} else { } else {
setError('Test-Fehler: ' + errorMsg) setError('Test-Fehler: ' + errorMsg)
@ -305,6 +570,7 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
error_message: errorMsg, error_message: errorMsg,
debug: debugData || { error: errorMsg } debug: debugData || { error: errorMsg }
}) })
setTestResultTab('response')
setShowDebug(true) // ALWAYS show debug on test, even on error setShowDebug(true) // ALWAYS show debug on test, even on error
} finally { } finally {
setTesting(false) setTesting(false)
@ -315,7 +581,12 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
if (!testResult) return if (!testResult) return
// Extract all placeholder data from test result // 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 = { const exportData = {
export_date: new Date().toISOString(), export_date: new Date().toISOString(),
prompt_slug: prompt?.slug || 'unknown', prompt_slug: prompt?.slug || 'unknown',
@ -711,7 +982,7 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
</div> </div>
)} )}
{/* Debug Output */} {/* Test-Ergebnis: Antwort · Prompt · Debug */}
{showDebug && testResult && ( {showDebug && testResult && (
<div style={{ <div style={{
marginTop: 16, marginTop: 16,
@ -724,12 +995,14 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: 12 marginBottom: 12,
flexWrap: 'wrap',
gap: 8
}}> }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}> <h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
🔬 Debug-Info Test-Ergebnis
</h3> </h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button <button
className="btn" className="btn"
onClick={handleExportPlaceholders} onClick={handleExportPlaceholders}
@ -739,31 +1012,120 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
background: 'var(--accent)', background: 'var(--accent)',
color: 'white' color: 'white'
}} }}
title="Exportiere alle Platzhalter mit Werten als JSON" title="Exportiere Platzhalter / Kontext als JSON"
> >
📋 Platzhalter exportieren 📋 Kontext exportieren
</button> </button>
<button <button
onClick={() => setShowDebug(false)} onClick={() => setShowDebug(false)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
aria-label="Schließen"
> >
<X size={16} color="var(--text3)" /> <X size={16} color="var(--text3)" />
</button> </button>
</div> </div>
</div> </div>
<pre style={{
fontSize: 11, <div
fontFamily: 'monospace', role="tablist"
background: 'var(--bg)', style={{
padding: 12, display: 'flex',
borderRadius: 6, gap: 6,
overflow: 'auto', marginBottom: 12,
maxHeight: 400, flexWrap: 'wrap',
lineHeight: 1.5, borderBottom: '1px solid var(--border)',
color: 'var(--text2)' paddingBottom: 8
}}> }}
{JSON.stringify(testResult.debug || testResult, null, 2)} >
</pre> {[
{ 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> </div>
)} )}

View File

@ -1,5 +1,5 @@
// Lightweight Markdown renderer handles the subset used by the AI: // 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 }) { export default function Markdown({ text }) {
if (!text) return null if (!text) return null
@ -7,6 +7,19 @@ export default function Markdown({ text }) {
const lines = text.split('\n') const lines = text.split('\n')
const elements = [] const elements = []
let i = 0 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) => { const parseLine = (line) => {
// Parse inline **bold** and *italic* // Parse inline **bold** and *italic*
@ -39,6 +52,44 @@ export default function Markdown({ text }) {
while (i < lines.length) { while (i < lines.length) {
const line = lines[i] 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) // Skip empty lines (add spacing)
if (line.trim() === '') { if (line.trim() === '') {
elements.push(<div key={i} style={{ height: 8 }} />) elements.push(<div key={i} style={{ height: 8 }} />)