Add provider fallback, visual schema builder, and UI improvements

Provider fallback system:
- Add optional fallback field to Provider dataclass
- Automatically try fallback provider when primary fails (timeout, error, offline)
- Prevent infinite loops with circular fallback detection
- Add fallback column and dropdown in providers UI

Visual schema builder for prompt steps:
- Add SchemaBuilderDialog with table-based field editor
- Support field name, type, description, and required flag
- Live JSON schema preview
- Integrate into PromptStepDialog with Edit Schema button
- Fix output_schema being lost when editing prompt steps

Other improvements:
- Add search box to tools page
- Improve schema example generation for structured output
- Update CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-02-21 00:50:18 -04:00
co-authored by Claude Opus 4.5
parent 8581775002
commit 71c6358b9d
9 changed files with 892 additions and 99 deletions
+195 -18
View File
@@ -2,6 +2,7 @@
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Optional
@@ -63,16 +64,110 @@ def validate_schema(data: dict, schema: dict) -> None:
print("Warning: jsonschema not installed, skipping schema validation", file=sys.stderr)
def append_schema_instructions(prompt: str, schema: dict) -> str:
def generate_schema_example(schema: dict) -> str:
"""
Append schema instructions to a prompt.
Generate a simple example JSON from a schema.
Small models work better with concrete examples than abstract JSON schemas.
Args:
schema: JSON schema to generate example from
Returns:
Example JSON string
"""
def build_example(schema_part: dict) -> any:
"""Recursively build example from schema."""
type_ = schema_part.get("type", "string")
if type_ == "string":
if "enum" in schema_part:
return schema_part["enum"][0]
desc = schema_part.get("description", "value")
return f"<{desc}>"
elif type_ == "number":
return 0.0
elif type_ == "integer":
return 0
elif type_ == "boolean":
return True
elif type_ == "array":
items = schema_part.get("items", {})
if items.get("type") == "object" and "properties" in items:
# Nested object array - generate one example item
return [build_example(items)]
elif items.get("type") == "string":
return ["<item1>", "<item2>"]
else:
return []
elif type_ == "object":
if "properties" in schema_part:
obj = {}
for key, spec in schema_part["properties"].items():
obj[key] = build_example(spec)
return obj
return {"...": "..."}
else:
return f"<{type_}>"
# Handle top-level schema
if schema.get("type") == "array":
example = build_example(schema)
elif schema.get("type") == "object" or "properties" in schema:
example = {}
for key, spec in schema.get("properties", {}).items():
example[key] = build_example(spec)
else:
example = build_example(schema)
return json.dumps(example, indent=2)
def prepend_schema_instructions(prompt: str, schema: dict) -> str:
"""
Prepend schema instructions to a prompt using example-based format.
Small models work better when format instructions come FIRST and use
concrete examples rather than abstract JSON schemas. This prevents
models from echoing the schema as content.
Args:
prompt: Original prompt text
schema: JSON schema the response must match
Returns:
Augmented prompt with schema instructions
Augmented prompt with schema instructions prepended
"""
example = generate_schema_example(schema)
# Build field guidance based on schema
props = schema.get("properties", {})
required = schema.get("required", [])
field_notes = []
if "reasoning" in props and "output" in props:
field_notes.append("Put your thinking in 'reasoning' and your answer in 'output'.")
if required:
field_notes.append(f"Required: {', '.join(required)}.")
field_guidance = " ".join(field_notes)
return f"""Respond with ONLY valid JSON in this format:
{example}
No text before or after the JSON. No markdown fences.{' ' + field_guidance if field_guidance else ''}
---
{prompt}"""
# Legacy function kept for rollback if needed
def _append_schema_instructions_legacy(prompt: str, schema: dict) -> str:
"""
DEPRECATED: Append schema instructions to a prompt.
This approach causes small models to echo the schema. Use
prepend_schema_instructions() instead.
"""
schema_json = json.dumps(schema, indent=2)
@@ -323,6 +418,94 @@ def substitute_variables(template: str, variables: dict, warn_non_scalar: bool =
return result
def _extract_json(text: str) -> dict | list | None:
"""
Extract JSON from LLM response using multiple strategies.
LLMs often wrap JSON in markdown, add explanations before/after,
or include other text. This function tries several approaches
to find and extract valid JSON.
Returns parsed JSON (dict or list) or None if extraction fails.
"""
if not text:
return None
text = text.strip()
# Strategy 1: Direct parse (already clean JSON)
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Strategy 2: Strip markdown code fences
if text.startswith("```"):
cleaned = re.sub(r'^```\w*\n?', '', text)
cleaned = re.sub(r'\n?```\s*$', '', cleaned)
try:
return json.loads(cleaned.strip())
except json.JSONDecodeError:
pass
# Strategy 3: Find first { or [ and match to last } or ]
# This handles "Here's the JSON:\n{...}" or "{...}\nExplanation"
first_brace = text.find('{')
first_bracket = text.find('[')
if first_brace == -1 and first_bracket == -1:
return None
# Determine which comes first and what we're looking for
if first_bracket == -1 or (first_brace != -1 and first_brace < first_bracket):
start_char, end_char = '{', '}'
start_pos = first_brace
else:
start_char, end_char = '[', ']'
start_pos = first_bracket
# Find matching end by counting braces/brackets
depth = 0
end_pos = -1
in_string = False
escape_next = False
for i in range(start_pos, len(text)):
char = text[i]
if escape_next:
escape_next = False
continue
if char == '\\':
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
continue
if in_string:
continue
if char == start_char:
depth += 1
elif char == end_char:
depth -= 1
if depth == 0:
end_pos = i
break
if end_pos != -1:
candidate = text[start_pos:end_pos + 1]
try:
return json.loads(candidate)
except json.JSONDecodeError:
pass
return None
def execute_prompt_step(
step: PromptStep,
variables: dict,
@@ -379,8 +562,8 @@ def execute_prompt_step(
# Structured output mode - enforce JSON with schema validation
schema = step.output_schema or DEFAULT_OUTPUT_SCHEMA
# Augment prompt with schema instructions
augmented_prompt = append_schema_instructions(prompt, schema)
# Augment prompt with schema instructions (prepended for small model compatibility)
augmented_prompt = prepend_schema_instructions(prompt, schema)
max_attempts = step.max_retries + 1
last_response = None
@@ -415,17 +598,10 @@ Please try again with valid JSON matching the schema exactly."""
last_response = result.text
# Strip markdown code fences if present (common model behavior)
text = result.text.strip()
if text.startswith("```"):
text = re.sub(r'^```\w*\n', '', text)
text = re.sub(r'\n```\s*$', '', text)
# Try to parse as JSON
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
last_error = f"Invalid JSON: {e}"
# Extract and parse JSON with multiple strategies
parsed = _extract_json(result.text)
if parsed is None:
last_error = f"Invalid JSON: Could not extract valid JSON from response"
if attempt == max_attempts - 1:
print(f"Prompt step failed after {step.max_retries} retry(s): {last_error}", file=sys.stderr)
if verbose:
@@ -445,8 +621,9 @@ Please try again with valid JSON matching the schema exactly."""
return "", False
continue
# Success - return normalized JSON string
return json.dumps(parsed), True
# Success - return parsed dict for code step compatibility
# Template substitution handles dicts via _get_nested_value
return parsed, True
# Should not reach here, but just in case
return "", False