mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
493 lines
15 KiB
Python
493 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test harness for dialog flow - walks through selecting all default options,
|
|
produces the resulting state object, and runs the JSONata transform.
|
|
|
|
Supports a test matrix mode where each field is tested with all its options
|
|
while other fields use defaults.
|
|
"""
|
|
|
|
import yaml
|
|
import json
|
|
import argparse
|
|
import zipfile
|
|
import io
|
|
from pathlib import Path
|
|
import jsonata
|
|
import requests
|
|
|
|
|
|
RESOURCES_DIR = Path(__file__).parent / "trustgraph_configurator/resources/dialog"
|
|
|
|
|
|
def load_flow():
|
|
"""Load the dialog flow YAML file."""
|
|
with open(RESOURCES_DIR / "trustgraph-flow.yaml") as f:
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
def load_jsonata_transform():
|
|
"""Load the JSONata transform file."""
|
|
with open(RESOURCES_DIR / "trustgraph-output.jsonata") as f:
|
|
return f.read()
|
|
|
|
|
|
def run_transform(state, transform_expr):
|
|
"""Run the JSONata transform on the state object."""
|
|
expr = jsonata.Jsonata(transform_expr)
|
|
return expr.evaluate(state)
|
|
|
|
|
|
def call_config_service(config):
|
|
"""
|
|
Call the configuration service to generate a deployment package.
|
|
Returns (success, message, zip_contents) tuple.
|
|
|
|
The API expects just the templates array, not the full config object.
|
|
"""
|
|
url = config["api_url"]
|
|
templates = config["templates"]
|
|
|
|
try:
|
|
response = requests.post(
|
|
url,
|
|
json=templates,
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
return False, f"HTTP {response.status_code}: {response.text[:200]}", None
|
|
|
|
# Verify it's a valid ZIP
|
|
try:
|
|
zip_data = io.BytesIO(response.content)
|
|
with zipfile.ZipFile(zip_data, 'r') as zf:
|
|
file_list = zf.namelist()
|
|
return True, f"ZIP with {len(file_list)} files", file_list
|
|
except zipfile.BadZipFile:
|
|
return False, "Response is not a valid ZIP file", None
|
|
|
|
except requests.exceptions.ConnectionError:
|
|
return False, "Connection refused - is the service running?", None
|
|
except requests.exceptions.Timeout:
|
|
return False, "Request timed out", None
|
|
except Exception as e:
|
|
return False, f"Error: {e}", None
|
|
|
|
|
|
def get_all_options(step):
|
|
"""Get all possible values for a step's input."""
|
|
input_def = step.get("input", {})
|
|
input_type = input_def.get("type")
|
|
|
|
if input_type == "select":
|
|
return [opt["value"] for opt in input_def.get("options", [])]
|
|
elif input_type == "toggle":
|
|
return [True, False]
|
|
elif input_type == "number":
|
|
# For numbers, just test default, min, and max
|
|
default = input_def.get("default")
|
|
min_val = input_def.get("min")
|
|
max_val = input_def.get("max")
|
|
values = []
|
|
if min_val is not None:
|
|
values.append(min_val)
|
|
if default is not None and default not in values:
|
|
values.append(default)
|
|
if max_val is not None and max_val not in values:
|
|
values.append(max_val)
|
|
return values
|
|
|
|
return []
|
|
|
|
|
|
def get_default_value(step):
|
|
"""Get the default value for a step's input."""
|
|
input_def = step.get("input", {})
|
|
input_type = input_def.get("type")
|
|
|
|
if input_type == "select":
|
|
options = input_def.get("options", [])
|
|
# Find recommended option, or use first
|
|
for opt in options:
|
|
if opt.get("recommended"):
|
|
return opt["value"]
|
|
return options[0]["value"] if options else None
|
|
|
|
elif input_type == "number":
|
|
return input_def.get("default")
|
|
|
|
elif input_type == "toggle":
|
|
return input_def.get("default", False)
|
|
|
|
return None
|
|
|
|
|
|
def evaluate_condition(condition, state):
|
|
"""
|
|
Evaluate a simple condition against the state.
|
|
Supports: "key = value", "key = true/false", "key < 'version'"
|
|
"""
|
|
if not condition:
|
|
return True
|
|
|
|
# Handle equality: "ocr.enabled = true"
|
|
if " = " in condition:
|
|
key, value = condition.split(" = ", 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
|
|
# Get nested key
|
|
state_value = state.get(key)
|
|
|
|
# Parse value
|
|
if value == "true":
|
|
return state_value is True
|
|
elif value == "false":
|
|
return state_value is False
|
|
else:
|
|
return str(state_value) == value
|
|
|
|
# Handle less-than for version comparisons: "version < '1.6.0'"
|
|
if " < " in condition:
|
|
key, value = condition.split(" < ", 1)
|
|
key = key.strip()
|
|
value = value.strip().strip("'\"")
|
|
state_value = state.get(key, "")
|
|
return str(state_value) < value
|
|
|
|
return False
|
|
|
|
|
|
def get_next_step(step, state):
|
|
"""Determine the next step based on transitions and current state."""
|
|
transitions = step.get("transitions", [])
|
|
|
|
for trans in transitions:
|
|
when = trans.get("when")
|
|
if when:
|
|
if evaluate_condition(when, state):
|
|
return trans.get("next")
|
|
else:
|
|
# Unconditional transition
|
|
return trans.get("next")
|
|
|
|
return None # Terminal state
|
|
|
|
|
|
def walk_flow(flow_data, overrides=None, verbose=True):
|
|
"""
|
|
Walk through the flow, return the state object.
|
|
|
|
Args:
|
|
flow_data: The parsed dialog flow YAML
|
|
overrides: Dict of {state_key: value} to override defaults
|
|
verbose: Whether to print progress
|
|
"""
|
|
state = {}
|
|
overrides = overrides or {}
|
|
steps = flow_data.get("steps", {})
|
|
current = flow_data.get("flow", {}).get("start")
|
|
visited_steps = []
|
|
|
|
if verbose:
|
|
print(f"Starting at: {current}")
|
|
print("-" * 60)
|
|
|
|
while current:
|
|
step = steps.get(current)
|
|
if not step:
|
|
if verbose:
|
|
print(f"ERROR: Step '{current}' not found!")
|
|
break
|
|
|
|
visited_steps.append(current)
|
|
title = step.get("title", current)
|
|
state_key = step.get("state_key")
|
|
|
|
# Get value - use override if present, otherwise default
|
|
if state_key:
|
|
if state_key in overrides:
|
|
value = overrides[state_key]
|
|
else:
|
|
value = get_default_value(step)
|
|
state[state_key] = value
|
|
|
|
if verbose:
|
|
is_override = state_key in overrides
|
|
marker = " [OVERRIDE]" if is_override else ""
|
|
print(f"Step: {current}")
|
|
print(f" Title: {title}")
|
|
print(f" State key: {state_key} = {value}{marker}")
|
|
else:
|
|
if verbose:
|
|
print(f"Step: {current}")
|
|
print(f" Title: {title}")
|
|
print(f" (no state key - review/terminal step)")
|
|
|
|
# Get next step
|
|
next_step = get_next_step(step, state)
|
|
if verbose:
|
|
if next_step:
|
|
print(f" -> Next: {next_step}")
|
|
else:
|
|
print(f" -> Terminal state")
|
|
print()
|
|
|
|
current = next_step
|
|
|
|
return state, visited_steps
|
|
|
|
|
|
def collect_fields_for_path(flow_data, overrides=None):
|
|
"""
|
|
Collect all fields and their possible values for a given path.
|
|
Returns a list of (step_name, state_key, options, default_value) tuples.
|
|
"""
|
|
fields = []
|
|
steps = flow_data.get("steps", {})
|
|
|
|
# Walk with given overrides to find the path
|
|
_, visited = walk_flow(flow_data, overrides=overrides, verbose=False)
|
|
|
|
for step_name in visited:
|
|
step = steps.get(step_name, {})
|
|
state_key = step.get("state_key")
|
|
if state_key:
|
|
options = get_all_options(step)
|
|
default = get_default_value(step)
|
|
if len(options) > 1: # Only include fields with choices
|
|
fields.append((step_name, state_key, options, default))
|
|
|
|
return fields
|
|
|
|
|
|
def collect_all_fields(flow_data):
|
|
"""
|
|
Collect all fields from the baseline (default) path.
|
|
"""
|
|
return collect_fields_for_path(flow_data, overrides=None)
|
|
|
|
|
|
def run_single_test(flow_data, transform_expr, overrides, description, test_num, results, call_api=False):
|
|
"""Run a single test case and record the result."""
|
|
print("-" * 70)
|
|
print(f"Test {test_num}: {description}")
|
|
print("-" * 70)
|
|
|
|
state, _ = walk_flow(flow_data, overrides=overrides, verbose=False)
|
|
|
|
try:
|
|
config = run_transform(state, transform_expr)
|
|
result = {
|
|
"test": test_num,
|
|
"description": description,
|
|
"overrides": overrides,
|
|
"state": state,
|
|
"config": config
|
|
}
|
|
|
|
print(f"State: {json.dumps(state, indent=2)}")
|
|
print(f"Templates: {[t['name'] for t in config['templates']]}")
|
|
|
|
# Optionally call the configuration service
|
|
if call_api:
|
|
success, message, files = call_config_service(config)
|
|
result["api_success"] = success
|
|
result["api_message"] = message
|
|
if files:
|
|
result["api_files"] = files
|
|
|
|
if success:
|
|
print(f"API: OK - {message}")
|
|
else:
|
|
print(f"API: FAILED - {message}")
|
|
result["error"] = f"API: {message}"
|
|
|
|
results.append(result)
|
|
|
|
except Exception as e:
|
|
results.append({
|
|
"test": test_num,
|
|
"description": description,
|
|
"overrides": overrides,
|
|
"state": state,
|
|
"error": str(e)
|
|
})
|
|
print(f"State: {json.dumps(state, indent=2)}")
|
|
print(f"ERROR: {e}")
|
|
print()
|
|
|
|
return test_num + 1
|
|
|
|
|
|
def run_test_matrix(flow_data, transform_expr, call_api=False):
|
|
"""
|
|
Run the test matrix - for each field, try all values while others use defaults.
|
|
When a toggle enables conditional fields, also test all options of those fields.
|
|
"""
|
|
baseline_fields = collect_all_fields(flow_data)
|
|
baseline_keys = {f[1] for f in baseline_fields}
|
|
|
|
print("=" * 70)
|
|
print("TEST MATRIX" + (" (with API validation)" if call_api else ""))
|
|
print("=" * 70)
|
|
print()
|
|
print(f"Found {len(baseline_fields)} fields with multiple options on baseline path:")
|
|
for step_name, state_key, options, default in baseline_fields:
|
|
print(f" - {state_key}: {len(options)} options (default: {default})")
|
|
print()
|
|
|
|
results = []
|
|
test_num = 1
|
|
|
|
# First, run the baseline (all defaults)
|
|
test_num = run_single_test(
|
|
flow_data, transform_expr,
|
|
overrides={},
|
|
description="BASELINE (all defaults)",
|
|
test_num=test_num,
|
|
results=results,
|
|
call_api=call_api
|
|
)
|
|
|
|
# For each field, try each non-default value
|
|
for step_name, state_key, options, default in baseline_fields:
|
|
for option in options:
|
|
if option == default:
|
|
continue # Skip default, already tested in baseline
|
|
|
|
overrides = {state_key: option}
|
|
test_num = run_single_test(
|
|
flow_data, transform_expr,
|
|
overrides=overrides,
|
|
description=f"{state_key} = {option}",
|
|
test_num=test_num,
|
|
results=results,
|
|
call_api=call_api
|
|
)
|
|
|
|
# Check if this override unlocks new fields (conditional paths)
|
|
unlocked_fields = collect_fields_for_path(flow_data, overrides=overrides)
|
|
unlocked_keys = {f[1] for f in unlocked_fields}
|
|
new_keys = unlocked_keys - baseline_keys
|
|
|
|
if new_keys:
|
|
# Test all non-default options of the newly unlocked fields
|
|
for uf_step, uf_key, uf_options, uf_default in unlocked_fields:
|
|
if uf_key not in new_keys:
|
|
continue
|
|
for uf_option in uf_options:
|
|
if uf_option == uf_default:
|
|
continue # Default already tested above
|
|
|
|
combined_overrides = {state_key: option, uf_key: uf_option}
|
|
test_num = run_single_test(
|
|
flow_data, transform_expr,
|
|
overrides=combined_overrides,
|
|
description=f"{state_key} = {option}, {uf_key} = {uf_option}",
|
|
test_num=test_num,
|
|
results=results,
|
|
call_api=call_api
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Test harness for dialog flow configuration"
|
|
)
|
|
parser.add_argument(
|
|
"--matrix", "-m",
|
|
action="store_true",
|
|
help="Run test matrix (each field with all options)"
|
|
)
|
|
parser.add_argument(
|
|
"--api", "-a",
|
|
action="store_true",
|
|
help="Call the configuration service API to validate each config"
|
|
)
|
|
parser.add_argument(
|
|
"--summary", "-s",
|
|
action="store_true",
|
|
help="Show summary only (with --matrix)"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
flow_data = load_flow()
|
|
transform_expr = load_jsonata_transform()
|
|
|
|
if args.matrix:
|
|
results = run_test_matrix(flow_data, transform_expr, call_api=args.api)
|
|
|
|
# Summary
|
|
print("=" * 70)
|
|
print("SUMMARY")
|
|
print("=" * 70)
|
|
print()
|
|
passed = [r for r in results if "error" not in r]
|
|
failed = [r for r in results if "error" in r]
|
|
print(f"Total tests: {len(results)}")
|
|
print(f"Passed: {len(passed)}")
|
|
print(f"Failed: {len(failed)}")
|
|
|
|
if args.api:
|
|
api_ok = [r for r in results if r.get("api_success")]
|
|
api_fail = [r for r in results if "api_success" in r and not r["api_success"]]
|
|
print(f"API OK: {len(api_ok)}")
|
|
print(f"API Failed: {len(api_fail)}")
|
|
|
|
if failed:
|
|
print()
|
|
print("Failed tests:")
|
|
for r in failed:
|
|
print(f" - Test {r['test']}: {r['description']}")
|
|
print(f" Error: {r['error']}")
|
|
else:
|
|
print("=" * 60)
|
|
print("Dialog Flow Test Harness - Default Options")
|
|
print("=" * 60)
|
|
print()
|
|
|
|
state, _ = walk_flow(flow_data)
|
|
|
|
print("=" * 60)
|
|
print("Final State Object:")
|
|
print("=" * 60)
|
|
print()
|
|
print(json.dumps(state, indent=2))
|
|
|
|
# Run JSONata transform
|
|
print()
|
|
print("=" * 60)
|
|
print("Running JSONata Transform...")
|
|
print("=" * 60)
|
|
print()
|
|
|
|
config = run_transform(state, transform_expr)
|
|
|
|
print("=" * 60)
|
|
print("Configuration Object (output of transform):")
|
|
print("=" * 60)
|
|
print()
|
|
print(json.dumps(config, indent=2))
|
|
|
|
# Optionally call the API
|
|
if args.api:
|
|
print()
|
|
print("=" * 60)
|
|
print("Calling Configuration Service...")
|
|
print("=" * 60)
|
|
print()
|
|
success, message, files = call_config_service(config)
|
|
if success:
|
|
print(f"OK: {message}")
|
|
print(f"Files: {files}")
|
|
else:
|
|
print(f"FAILED: {message}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|