Implements the complete AI flow builder feature using a guided 4-stage wizard (Foundation → Scaffold → Branch Detail → Review & Assemble). AI assists at bounded points using Claude Haiku for cost-efficient structured JSON generation (~$0.01-0.03/flow). Backend: new models (ai_conversations, ai_usage), Alembic migration, quota enforcement with billing anchor, Anthropic API integration with prompt caching, tree validation, conversation CRUD with 24h TTL, APScheduler cleanup job, 5 API endpoints, Pydantic schemas. Frontend: TypeScript types, API client, Zustand store for wizard state, 7 components (modal, step indicator, foundation form, branch selector, branch detail view, tree preview, quota display), MyTreesPage integration with "Build with AI" button (hidden when AI not configured). Tests: 14 validator unit tests + 11 endpoint integration tests with mocked Anthropic (zero real API spend). All 25 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
184 lines
6.5 KiB
Python
184 lines
6.5 KiB
Python
"""Tests for AI-generated tree structure validation."""
|
|
import pytest
|
|
|
|
from app.core.ai_tree_validator import validate_generated_tree, count_tree_stats
|
|
|
|
|
|
def _make_valid_tree():
|
|
"""Helper: minimal valid tree for testing."""
|
|
return {
|
|
"id": "root",
|
|
"type": "decision",
|
|
"question": "Is the service running?",
|
|
"options": [
|
|
{"id": "opt-yes", "label": "Yes", "next_node_id": "check-logs"},
|
|
{"id": "opt-no", "label": "No", "next_node_id": "restart-service"},
|
|
],
|
|
"children": [
|
|
{
|
|
"id": "check-logs",
|
|
"type": "decision",
|
|
"question": "Are there errors in the logs?",
|
|
"options": [
|
|
{"id": "opt-errors", "label": "Yes", "next_node_id": "fix-errors"},
|
|
{"id": "opt-clean", "label": "No", "next_node_id": "escalate"},
|
|
],
|
|
"children": [
|
|
{
|
|
"id": "fix-errors",
|
|
"type": "solution",
|
|
"title": "Fix Errors",
|
|
"description": "Apply the fix for the errors found.",
|
|
},
|
|
{
|
|
"id": "escalate",
|
|
"type": "solution",
|
|
"title": "Escalate",
|
|
"description": "No errors found; escalate to Tier 2.",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"id": "restart-service",
|
|
"type": "action",
|
|
"title": "Restart the Service",
|
|
"description": "Restart the service and verify.",
|
|
"commands": ["Restart-Service -Name 'TestService'"],
|
|
"children": [
|
|
{
|
|
"id": "service-resolved",
|
|
"type": "solution",
|
|
"title": "Service Restored",
|
|
"description": "Service is running after restart.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
class TestValidTree:
|
|
def test_valid_tree_passes(self):
|
|
errors = validate_generated_tree(_make_valid_tree())
|
|
assert errors == []
|
|
|
|
def test_not_a_dict(self):
|
|
errors = validate_generated_tree("not a dict")
|
|
assert any("must be a JSON object" in e for e in errors)
|
|
|
|
def test_root_not_decision(self):
|
|
tree = _make_valid_tree()
|
|
tree["type"] = "action"
|
|
tree["title"] = "Fake"
|
|
errors = validate_generated_tree(tree)
|
|
assert any("Root node must be type 'decision'" in e for e in errors)
|
|
|
|
|
|
class TestNodeValidation:
|
|
def test_missing_id(self):
|
|
tree = _make_valid_tree()
|
|
del tree["children"][0]["id"]
|
|
errors = validate_generated_tree(tree)
|
|
assert any("missing 'id'" in e for e in errors)
|
|
|
|
def test_duplicate_ids(self):
|
|
tree = _make_valid_tree()
|
|
tree["children"][1]["id"] = "check-logs" # same as sibling
|
|
errors = validate_generated_tree(tree)
|
|
assert any("Duplicate node ID" in e for e in errors)
|
|
|
|
def test_invalid_node_type(self):
|
|
tree = _make_valid_tree()
|
|
tree["children"][0]["type"] = "unknown"
|
|
errors = validate_generated_tree(tree)
|
|
assert any("invalid type" in e for e in errors)
|
|
|
|
def test_decision_missing_options(self):
|
|
tree = _make_valid_tree()
|
|
del tree["children"][0]["options"]
|
|
errors = validate_generated_tree(tree)
|
|
assert any("missing fields" in e for e in errors)
|
|
|
|
def test_decision_less_than_2_options(self):
|
|
tree = _make_valid_tree()
|
|
tree["children"][0]["options"] = [
|
|
{"id": "opt-1", "label": "Only", "next_node_id": "fix-errors"}
|
|
]
|
|
errors = validate_generated_tree(tree)
|
|
assert any("at least 2 options" in e for e in errors)
|
|
|
|
|
|
class TestReferenceIntegrity:
|
|
def test_option_references_nonexistent_child(self):
|
|
tree = _make_valid_tree()
|
|
tree["options"][0]["next_node_id"] = "nonexistent"
|
|
errors = validate_generated_tree(tree)
|
|
assert any("non-existent child" in e for e in errors)
|
|
|
|
def test_duplicate_option_ids(self):
|
|
tree = _make_valid_tree()
|
|
tree["options"][0]["id"] = "same"
|
|
tree["options"][1]["id"] = "same"
|
|
errors = validate_generated_tree(tree)
|
|
assert any("Duplicate option ID" in e for e in errors)
|
|
|
|
|
|
class TestGlobalChecks:
|
|
def test_too_few_nodes(self):
|
|
tree = {
|
|
"id": "root",
|
|
"type": "decision",
|
|
"question": "Test?",
|
|
"options": [
|
|
{"id": "o1", "label": "A", "next_node_id": "s1"},
|
|
{"id": "o2", "label": "B", "next_node_id": "s2"},
|
|
],
|
|
"children": [
|
|
{"id": "s1", "type": "solution", "title": "S1", "description": "D1"},
|
|
{"id": "s2", "type": "solution", "title": "S2", "description": "D2"},
|
|
],
|
|
}
|
|
errors = validate_generated_tree(tree)
|
|
assert any("Minimum 5 required" in e for e in errors)
|
|
|
|
def test_too_few_solutions(self):
|
|
tree = _make_valid_tree()
|
|
# Remove all solutions except one — replace children of check-logs
|
|
tree["children"][0]["children"] = [
|
|
{
|
|
"id": "only-solution",
|
|
"type": "solution",
|
|
"title": "Only",
|
|
"description": "Only solution",
|
|
}
|
|
]
|
|
tree["children"][0]["options"] = [
|
|
{"id": "o1", "label": "A", "next_node_id": "only-solution"},
|
|
{"id": "o2", "label": "B", "next_node_id": "only-solution"},
|
|
]
|
|
# Now restart-service branch has 1 solution, check-logs has 1 = total 2
|
|
# Remove one more to get to 1
|
|
tree["children"][1]["children"] = []
|
|
errors = validate_generated_tree(tree)
|
|
assert any("solution" in e.lower() for e in errors)
|
|
|
|
|
|
class TestDeadEndDetection:
|
|
def test_dead_end_action_node(self):
|
|
tree = _make_valid_tree()
|
|
# Remove restart-service's children — becomes dead end
|
|
tree["children"][1]["children"] = []
|
|
errors = validate_generated_tree(tree)
|
|
assert any("dead end" in e for e in errors)
|
|
|
|
|
|
class TestCountTreeStats:
|
|
def test_stats_correct(self):
|
|
tree = _make_valid_tree()
|
|
stats = count_tree_stats(tree)
|
|
assert stats["node_count"] == 6
|
|
assert stats["decision_count"] == 2
|
|
assert stats["action_count"] == 1
|
|
assert stats["solution_count"] == 3
|
|
assert stats["depth"] >= 3
|