diff --git a/backend/prompt_executor.py b/backend/prompt_executor.py index 0970892..eddf4c6 100644 --- a/backend/prompt_executor.py +++ b/backend/prompt_executor.py @@ -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) diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 5153ce5..1f32011 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -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: diff --git a/frontend/src/components/UnifiedPromptModal.jsx b/frontend/src/components/UnifiedPromptModal.jsx index e99bd99..4281866 100644 --- a/frontend/src/components/UnifiedPromptModal.jsx +++ b/frontend/src/components/UnifiedPromptModal.jsx @@ -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 ( +
+
+ {children} +
+ {showToggle && ( +
+ +
+ )} +
+ ) +} + +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 ( +
+ {typeof msg === 'string' ? msg : JSON.stringify(msg, null, 2)} +
+ ) + } + + const out = testResult.output + const fmt = testResult.output_format + const typ = testResult.type + + if (out == null) { + return Keine Ausgabe + } + + const proseBox = (content, contentKey) => ( +
+ {contentKey != null ? ( + {content} + ) : ( + content + )} +
+ ) + + if (typ === 'pipeline' && out && typeof out === 'object' && !Array.isArray(out)) { + return ( +
+ {Object.entries(out).map(([key, val]) => ( +
+
+ {key} +
+ {typeof val === 'string' ? ( + proseBox( + , + `pipe-${key}-${val.length}-${val.slice(0, 120)}` + ) + ) : ( + +
+                  {JSON.stringify(val, null, 2)}
+                
+
+ )} +
+ ))} +
+ ) + } + + if (typeof out === 'string') { + if (fmt === 'json') { + try { + const parsed = JSON.parse(out) + const jsonStr = JSON.stringify(parsed, null, 2) + return ( + +
+              {jsonStr}
+            
+
+ ) + } catch { + return proseBox( +
{out}
, + `json-fail-${out.length}-${out.slice(0, 80)}` + ) + } + } + return proseBox(, `text-out-${out.length}-${out.slice(0, 120)}`) + } + + if (typeof out === 'object') { + const jsonStr = JSON.stringify(out, null, 2) + return ( + +
+          {jsonStr}
+        
+
+ ) + } + + return {String(out)} +} /** * 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 }) { )} - {/* Debug Output */} + {/* Test-Ergebnis: Antwort · Prompt · Debug */} {showDebug && testResult && (

- 🔬 Debug-Info + Test-Ergebnis

-
+
-
-              {JSON.stringify(testResult.debug || testResult, null, 2)}
-            
+ +
+ {[ + { id: 'response', label: 'Antwort' }, + { id: 'prompt', label: 'Prompt (an LLM)' }, + { id: 'debug', label: 'Debug' } + ].map((tab) => ( + + ))} +
+ +
+ {testResultTab === 'response' && ( +
+ {renderTestOutput(testResult)} +
+ )} + + {testResultTab === 'prompt' && ( +
+                  {buildPromptTextForDisplay(testResult) || '(Kein Prompt-Text in der Antwort — evtl. Fehler vor Ausführung.)'}
+                
+ )} + + {testResultTab === 'debug' && ( +
+                  {JSON.stringify(
+                    sanitizeDebugForPanel(
+                      extractExecutionDebug(testResult),
+                      testResult.error ? null : testResult.type
+                    ),
+                    null,
+                    2
+                  )}
+                
+ )} +
)} diff --git a/frontend/src/utils/Markdown.jsx b/frontend/src/utils/Markdown.jsx index 328fa8b..ece6419 100644 --- a/frontend/src/utils/Markdown.jsx +++ b/frontend/src/utils/Markdown.jsx @@ -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( +
+ {lang && ( +
+ {lang} +
+ )} +
+            {code}
+          
+
+ ) + continue + } + // Skip empty lines (add spacing) if (line.trim() === '') { elements.push(
)