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:
+195
-18
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user