Skip to main content

#!/usr/bin/env python3 """ Interactive Query Template Creator for CODITECT Context Graph.

Implements J.4.8.5: Interactive mode for creating custom query templates. Guides users through template creation with validation against ADR-154 schema.

Usage: from scripts.context_graph.query_template_creator import QueryTemplateCreator

creator = QueryTemplateCreator()
template_path = creator.create_interactive()

Or via CLI: python3 scripts/context_graph/query_template_creator.py --interactive python3 scripts/context_graph/query_template_creator.py --quick "my-template"

Author: CODITECT Team Version: 1.0.0 Task: J.4.8.5 ADR: ADR-154 (Context Graph Query DSL and Agent Workflow) """

import json import re import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple

import yaml

Default paths

CODITECT_DATA = Path.home() / "PROJECTS" / ".coditect-data" USER_TEMPLATES_DIR = CODITECT_DATA / "queries" / "templates"

Valid values from schema

VALID_CATEGORIES = ["agent-context", "analytics", "audit", "search", "workflow", "debugging"]

VALID_ANCHOR_TYPES = [ "track", "decision", "error_solution", "skill_learning", "component", "session", "file", "function", "adr", "policy", "recent_decisions", "recent_errors", "recent_sessions" ]

VALID_EXPANSION_STRATEGIES = ["anchor", "semantic", "policy_first", "hybrid"]

VALID_EDGE_TYPES = [ "CALLS", "INVOKES", "PRODUCES", "BELONGS_TO", "DEFINES", "REFERENCES", "SIMILAR_TO", "USES", "SOLVES", "GOVERNED_BY", "CREATED_BY" ]

VALID_PRUNING_STRATEGIES = ["token_budget", "relevance_threshold", "depth_limit", "combined"]

VALID_TRACKS = [ # Technical tracks "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", # PCF Business tracks "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", # Extension tracks "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK" ]

Common agent types

COMMON_AGENT_TYPES = [ "senior-architect", "devops-engineer", "security-specialist", "testing-specialist", "codi-documentation-writer", "database-architect", "frontend-react-typescript-expert", "backend-architect", "code-reviewer" ]

@dataclass class TemplateBuilder: """Builder for constructing query template data."""

name: str = ""
version: str = "1.0.0"
description: str = ""
author: str = "coditect-user"
category: str = "agent-context"
track: Optional[str] = None
agent_types: List[str] = field(default_factory=list)
tags: List[str] = field(default_factory=list)

# Query section
anchors: List[Dict[str, Any]] = field(default_factory=list)
expansion_strategy: str = "semantic"
expansion_depth: int = 2
expansion_edge_types: List[str] = field(default_factory=list)
expansion_max_nodes: int = 60

# Pruning section
pruning_strategy: str = "token_budget"
pruning_token_budget: int = 4000
pruning_relevance_threshold: float = 0.35
pruning_type_priorities: Dict[str, float] = field(default_factory=dict)

# Output section
output_format: str = "markdown"
output_max_tokens: int = 4000
output_include_metadata: bool = True
output_group_by_type: bool = True

# Workflow section
workflow_persist_results: bool = False
workflow_include_prior_decisions: bool = True

def to_dict(self) -> Dict[str, Any]:
"""Convert builder state to template dictionary."""
template = {
"name": self.name,
"version": self.version,
"description": self.description,
"author": self.author,
"category": self.category,
}

if self.track:
template["track"] = self.track
if self.agent_types:
template["agent_types"] = self.agent_types
if self.tags:
template["tags"] = self.tags

# Query section
template["query"] = {
"anchors": self.anchors,
"expansion": {
"strategy": self.expansion_strategy,
"depth": self.expansion_depth,
"max_nodes": self.expansion_max_nodes,
},
"pruning": {
"strategy": self.pruning_strategy,
"token_budget": self.pruning_token_budget,
"relevance_threshold": self.pruning_relevance_threshold,
},
}

if self.expansion_edge_types:
template["query"]["expansion"]["edge_types"] = self.expansion_edge_types

if self.pruning_type_priorities:
template["query"]["pruning"]["type_priorities"] = self.pruning_type_priorities

# Output section
template["output"] = {
"format": self.output_format,
"max_tokens": self.output_max_tokens,
"include_metadata": self.output_include_metadata,
"group_by_type": self.output_group_by_type,
}

# Workflow section
template["workflow"] = {
"persist_results": self.workflow_persist_results,
"include_prior_decisions": self.workflow_include_prior_decisions,
}

return template

def to_yaml(self) -> str:
"""Convert to YAML string with header comment."""
data = self.to_dict()

# Create header comment
header = f"""# {self.description}

Track {self.track or 'General'}: {self.category.replace('-', ' ').title()}

""" return header + yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)

class QueryTemplateCreator: """Interactive creator for query templates."""

def __init__(self, output_dir: Optional[Path] = None):
self.output_dir = output_dir or USER_TEMPLATES_DIR
self.builder = TemplateBuilder()

def _prompt(self, message: str, default: str = "") -> str:
"""Prompt user for input with optional default."""
if default:
prompt_text = f"{message} [{default}]: "
else:
prompt_text = f"{message}: "

response = input(prompt_text).strip()
return response if response else default

def _prompt_choice(self, message: str, choices: List[str], default: str = "") -> str:
"""Prompt user to choose from a list."""
print(f"\n{message}")
for i, choice in enumerate(choices, 1):
marker = " (default)" if choice == default else ""
print(f" {i}. {choice}{marker}")

while True:
response = input(f"Enter choice (1-{len(choices)}) or name: ").strip()

if not response and default:
return default

# Try numeric selection
if response.isdigit():
idx = int(response) - 1
if 0 <= idx < len(choices):
return choices[idx]

# Try name match
if response.lower() in [c.lower() for c in choices]:
for c in choices:
if c.lower() == response.lower():
return c

print(f"Invalid choice. Please enter 1-{len(choices)} or a valid name.")

def _prompt_multi_choice(self, message: str, choices: List[str], max_items: int = 5) -> List[str]:
"""Prompt user to select multiple items."""
print(f"\n{message}")
for i, choice in enumerate(choices, 1):
print(f" {i}. {choice}")
print(f"\nEnter numbers separated by commas (e.g., 1,3,5) or 'none':")

response = input("> ").strip()

if not response or response.lower() == "none":
return []

selected = []
for part in response.split(","):
part = part.strip()
if part.isdigit():
idx = int(part) - 1
if 0 <= idx < len(choices) and choices[idx] not in selected:
selected.append(choices[idx])
if len(selected) >= max_items:
break

return selected

def _prompt_yes_no(self, message: str, default: bool = True) -> bool:
"""Prompt for yes/no."""
default_str = "Y/n" if default else "y/N"
response = input(f"{message} [{default_str}]: ").strip().lower()

if not response:
return default
return response in ("y", "yes", "true", "1")

def _prompt_int(self, message: str, default: int, min_val: int = 1, max_val: int = 100000) -> int:
"""Prompt for integer with validation."""
while True:
response = input(f"{message} [{default}]: ").strip()

if not response:
return default

try:
value = int(response)
if min_val <= value <= max_val:
return value
print(f"Value must be between {min_val} and {max_val}")
except ValueError:
print("Please enter a valid number")

def _prompt_float(self, message: str, default: float, min_val: float = 0.0, max_val: float = 1.0) -> float:
"""Prompt for float with validation."""
while True:
response = input(f"{message} [{default}]: ").strip()

if not response:
return default

try:
value = float(response)
if min_val <= value <= max_val:
return value
print(f"Value must be between {min_val} and {max_val}")
except ValueError:
print("Please enter a valid number")

def _validate_name(self, name: str) -> Tuple[bool, str]:
"""Validate template name."""
if not name:
return False, "Name cannot be empty"

if len(name) < 3:
return False, "Name must be at least 3 characters"

if len(name) > 64:
return False, "Name must be at most 64 characters"

if not re.match(r'^[a-z][a-z0-9-]*[a-z0-9]$', name):
return False, "Name must be kebab-case (lowercase letters, numbers, hyphens)"

return True, ""

def _collect_basic_info(self) -> None:
"""Collect basic template information."""
print("\n" + "=" * 60)
print("CODITECT Query Template Creator")
print("=" * 60)
print("\nThis wizard will guide you through creating a custom query template.")
print("Press Enter to accept defaults shown in [brackets].\n")

# Name
while True:
name = self._prompt("Template name (kebab-case)", "my-custom-context")
valid, error = self._validate_name(name)
if valid:
self.builder.name = name
break
print(f"Error: {error}")

# Description
self.builder.description = self._prompt(
"Description (what does this template retrieve?)",
f"Load context for {name.replace('-', ' ')}"
)

# Category
self.builder.category = self._prompt_choice(
"Category:", VALID_CATEGORIES, "agent-context"
)

# Track
if self._prompt_yes_no("Associate with a PILOT track?", True):
self.builder.track = self._prompt_choice(
"Track:", VALID_TRACKS[:14] + ["Other..."], "J"
)
if self.builder.track == "Other...":
self.builder.track = self._prompt("Enter track letter(s)").upper()

# Agent types
if self._prompt_yes_no("Specify agent types this template is designed for?", True):
self.builder.agent_types = self._prompt_multi_choice(
"Select agent types:", COMMON_AGENT_TYPES, max_items=5
)

# Tags
tags_input = self._prompt("Tags (comma-separated, for search)", "")
if tags_input:
self.builder.tags = [t.strip().lower() for t in tags_input.split(",")][:10]

def _collect_anchors(self) -> None:
"""Collect anchor definitions."""
print("\n" + "-" * 40)
print("ANCHOR CONFIGURATION")
print("-" * 40)
print("\nAnchors define the starting points for context graph expansion.")
print("You need at least one anchor.\n")

while True:
anchor_type = self._prompt_choice(
"Anchor type:", VALID_ANCHOR_TYPES, "track"
)

anchor = {"type": anchor_type}

# Type-specific configuration
if anchor_type == "track":
anchor["id"] = self._prompt("Track ID (e.g., J, A, H)", self.builder.track or "J")

elif anchor_type in ("recent_decisions", "recent_errors", "recent_sessions"):
anchor["limit"] = self._prompt_int("How many recent items?", 20, 1, 100)

elif anchor_type in ("decision", "component", "adr"):
filter_val = self._prompt(f"Filter expression (SQL WHERE clause, or leave empty)", "")
if filter_val:
anchor["filter"] = filter_val
anchor["limit"] = self._prompt_int("Maximum anchors", 10, 1, 100)

else:
anchor["limit"] = self._prompt_int("Maximum anchors", 10, 1, 100)

self.builder.anchors.append(anchor)

if not self._prompt_yes_no("Add another anchor?", False):
break

def _collect_expansion(self) -> None:
"""Collect expansion configuration."""
print("\n" + "-" * 40)
print("EXPANSION CONFIGURATION")
print("-" * 40)
print("\nExpansion controls how the context graph grows from anchors.\n")

self.builder.expansion_strategy = self._prompt_choice(
"Expansion strategy:", VALID_EXPANSION_STRATEGIES, "semantic"
)

self.builder.expansion_depth = self._prompt_int(
"Expansion depth (1-5)", 2, 1, 5
)

self.builder.expansion_max_nodes = self._prompt_int(
"Maximum nodes", 60, 10, 1000
)

if self._prompt_yes_no("Specify edge types to traverse?", False):
self.builder.expansion_edge_types = self._prompt_multi_choice(
"Edge types:", VALID_EDGE_TYPES, max_items=10
)

def _collect_pruning(self) -> None:
"""Collect pruning configuration."""
print("\n" + "-" * 40)
print("PRUNING CONFIGURATION")
print("-" * 40)
print("\nPruning controls how the graph is reduced to fit token budget.\n")

self.builder.pruning_strategy = self._prompt_choice(
"Pruning strategy:", VALID_PRUNING_STRATEGIES, "token_budget"
)

self.builder.pruning_token_budget = self._prompt_int(
"Token budget", 4000, 100, 100000
)

self.builder.pruning_relevance_threshold = self._prompt_float(
"Relevance threshold (0.0-1.0)", 0.35, 0.0, 1.0
)

if self._prompt_yes_no("Set type priorities (higher = keep)?", False):
print("\nEnter priority (0.0-10.0) for each type, or skip:")
for node_type in ["decision", "adr", "component", "error_solution", "skill_learning"]:
priority = self._prompt(f" {node_type}", "")
if priority:
try:
self.builder.pruning_type_priorities[node_type] = float(priority)
except ValueError:
pass

def _collect_output(self) -> None:
"""Collect output configuration."""
print("\n" + "-" * 40)
print("OUTPUT CONFIGURATION")
print("-" * 40)

self.builder.output_format = self._prompt_choice(
"Output format:", ["markdown", "json", "text"], "markdown"
)

self.builder.output_max_tokens = self._prompt_int(
"Max output tokens", 4000, 100, 100000
)

self.builder.output_include_metadata = self._prompt_yes_no(
"Include metadata in output?", True
)

self.builder.output_group_by_type = self._prompt_yes_no(
"Group results by node type?", True
)

def _collect_workflow(self) -> None:
"""Collect workflow configuration."""
print("\n" + "-" * 40)
print("WORKFLOW CONFIGURATION")
print("-" * 40)

self.builder.workflow_persist_results = self._prompt_yes_no(
"Persist query results to database?", False
)

self.builder.workflow_include_prior_decisions = self._prompt_yes_no(
"Include prior workflow decisions?", True
)

def _preview_and_confirm(self) -> bool:
"""Show preview and confirm save."""
print("\n" + "=" * 60)
print("TEMPLATE PREVIEW")
print("=" * 60)

yaml_content = self.builder.to_yaml()
print(yaml_content)

print("=" * 60)
return self._prompt_yes_no("\nSave this template?", True)

def _save_template(self) -> Path:
"""Save template to file."""
# Ensure output directory exists
self.output_dir.mkdir(parents=True, exist_ok=True)

# Generate filename
filename = f"{self.builder.name}.yaml"
filepath = self.output_dir / filename

# Check for existing file
if filepath.exists():
if not self._prompt_yes_no(f"File {filename} exists. Overwrite?", False):
# Generate unique name
counter = 1
while filepath.exists():
filename = f"{self.builder.name}-{counter}.yaml"
filepath = self.output_dir / filename
counter += 1

# Write file
with open(filepath, "w") as f:
f.write(self.builder.to_yaml())

return filepath

def create_interactive(self) -> Optional[Path]:
"""Run full interactive creation wizard."""
try:
self._collect_basic_info()
self._collect_anchors()
self._collect_expansion()
self._collect_pruning()
self._collect_output()
self._collect_workflow()

if self._preview_and_confirm():
filepath = self._save_template()
print(f"\n✅ Template saved to: {filepath}")
print(f"\nUse with: /cxq --template {self.builder.name}")
return filepath
else:
print("\n❌ Template creation cancelled.")
return None

except KeyboardInterrupt:
print("\n\n❌ Template creation cancelled.")
return None

def create_quick(self, name: str, description: str = "", track: str = "J",
category: str = "agent-context") -> Path:
"""Create template with minimal prompts."""
valid, error = self._validate_name(name)
if not valid:
raise ValueError(f"Invalid template name: {error}")

self.builder.name = name
self.builder.description = description or f"Custom context for {name.replace('-', ' ')}"
self.builder.track = track
self.builder.category = category

# Use sensible defaults
self.builder.anchors = [
{"type": "track", "id": track},
{"type": "recent_decisions", "limit": 20}
]

filepath = self._save_template()
return filepath

def create_from_existing(self, source_name: str, new_name: str) -> Optional[Path]:
"""Clone an existing template with a new name."""
try:
from scripts.context_graph.query_templates import QueryTemplateRegistry

registry = QueryTemplateRegistry()
source = registry.get(source_name)

if not source:
print(f"Error: Template '{source_name}' not found")
return None

valid, error = self._validate_name(new_name)
if not valid:
print(f"Error: Invalid name: {error}")
return None

# Copy template data
self.builder.name = new_name
self.builder.description = source.description
self.builder.category = source.category
self.builder.track = source.track
self.builder.agent_types = source.agent_types.copy()
self.builder.tags = source.tags.copy()

# Copy query config
self.builder.anchors = [
{"type": a.type, "id": a.id, "filter": a.filter, "limit": a.limit}
for a in source.anchors
]
self.builder.expansion_strategy = source.expansion.strategy
self.builder.expansion_depth = source.expansion.depth
self.builder.expansion_edge_types = source.expansion.edge_types.copy()
self.builder.expansion_max_nodes = source.expansion.max_nodes

self.builder.pruning_strategy = source.pruning.strategy
self.builder.pruning_token_budget = source.pruning.token_budget
self.builder.pruning_relevance_threshold = source.pruning.relevance_threshold
self.builder.pruning_type_priorities = source.pruning.type_priorities.copy()

self.builder.output_format = source.output.format
self.builder.output_max_tokens = source.output.max_tokens

filepath = self._save_template()
print(f"✅ Cloned '{source_name}' to '{new_name}'")
print(f" Saved to: {filepath}")
return filepath

except ImportError:
print("Error: Query template registry not available")
return None

def main(): """CLI for query template creation.""" import argparse

parser = argparse.ArgumentParser(
description="CODITECT Query Template Creator (J.4.8.5)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""

Examples: %(prog)s --interactive # Full interactive wizard %(prog)s --quick my-template # Quick create with defaults %(prog)s --clone architecture-context my-arch # Clone existing %(prog)s --output-dir ./templates # Custom output directory """ )

parser.add_argument("--interactive", "-i", action="store_true",
help="Run full interactive wizard")
parser.add_argument("--quick", "-q", metavar="NAME",
help="Quick create with name (uses defaults)")
parser.add_argument("--clone", "-c", nargs=2, metavar=("SOURCE", "NAME"),
help="Clone existing template with new name")
parser.add_argument("--output-dir", "-o", type=Path,
help=f"Output directory (default: {USER_TEMPLATES_DIR})")
parser.add_argument("--description", "-d", default="",
help="Template description (for --quick)")
parser.add_argument("--track", "-t", default="J",
help="Track association (for --quick)")
parser.add_argument("--category", default="agent-context",
help="Category (for --quick)")

args = parser.parse_args()

output_dir = args.output_dir or USER_TEMPLATES_DIR
creator = QueryTemplateCreator(output_dir=output_dir)

if args.interactive:
creator.create_interactive()
elif args.quick:
try:
filepath = creator.create_quick(
args.quick,
description=args.description,
track=args.track,
category=args.category
)
print(f"✅ Template created: {filepath}")
except ValueError as e:
print(f"Error: {e}")
return 1
elif args.clone:
creator.create_from_existing(args.clone[0], args.clone[1])
else:
# Default to interactive
creator.create_interactive()

return 0

if name == "main": sys.exit(main())