493 lines
17 KiB
Python
493 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Task Context Builder
|
|
====================
|
|
|
|
Builds focused context for a specific task by searching relevant services.
|
|
This is the "RAG-like" component that finds what files matter for THIS task.
|
|
|
|
Usage:
|
|
# Find context for a task across specific services
|
|
python auto-build/context.py \
|
|
--services backend,scraper \
|
|
--keywords "retry,error,proxy" \
|
|
--task "Add retry logic when proxies fail" \
|
|
--output auto-build/specs/001-retry/context.json
|
|
|
|
# Use project index to auto-suggest services
|
|
python auto-build/context.py \
|
|
--task "Add retry logic when proxies fail" \
|
|
--output context.json
|
|
|
|
The context builder will:
|
|
1. Load project index (from analyzer)
|
|
2. Search specified services for relevant files
|
|
3. Find similar implementations to reference
|
|
4. Output focused context for AI agents
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from dataclasses import dataclass, field, asdict
|
|
|
|
# Directories to skip
|
|
SKIP_DIRS = {
|
|
"node_modules", ".git", "__pycache__", ".venv", "venv", "dist", "build",
|
|
".next", ".nuxt", "target", "vendor", ".idea", ".vscode", "auto-build",
|
|
".pytest_cache", ".mypy_cache", "coverage", ".turbo", ".cache",
|
|
}
|
|
|
|
# File extensions to search
|
|
CODE_EXTENSIONS = {
|
|
".py", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte",
|
|
".go", ".rs", ".rb", ".php",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class FileMatch:
|
|
"""A file that matched the search criteria."""
|
|
path: str
|
|
service: str
|
|
reason: str
|
|
relevance_score: float = 0.0
|
|
matching_lines: list[tuple[int, str]] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class TaskContext:
|
|
"""Complete context for a task."""
|
|
task_description: str
|
|
scoped_services: list[str]
|
|
files_to_modify: list[dict]
|
|
files_to_reference: list[dict]
|
|
patterns_discovered: dict[str, str]
|
|
service_contexts: dict[str, dict]
|
|
|
|
|
|
class ContextBuilder:
|
|
"""Builds task-specific context by searching the codebase."""
|
|
|
|
def __init__(self, project_dir: Path, project_index: dict | None = None):
|
|
self.project_dir = project_dir.resolve()
|
|
self.project_index = project_index or self._load_project_index()
|
|
|
|
def _load_project_index(self) -> dict:
|
|
"""Load project index from file or create new one."""
|
|
index_file = self.project_dir / "auto-build" / "project_index.json"
|
|
if index_file.exists():
|
|
with open(index_file) as f:
|
|
return json.load(f)
|
|
|
|
# Try to create one
|
|
from analyzer import analyze_project
|
|
return analyze_project(self.project_dir)
|
|
|
|
def build_context(
|
|
self,
|
|
task: str,
|
|
services: list[str] | None = None,
|
|
keywords: list[str] | None = None,
|
|
) -> TaskContext:
|
|
"""
|
|
Build context for a specific task.
|
|
|
|
Args:
|
|
task: Description of the task
|
|
services: List of service names to search (None = auto-detect)
|
|
keywords: Additional keywords to search for
|
|
|
|
Returns:
|
|
TaskContext with relevant files and patterns
|
|
"""
|
|
# Auto-detect services if not specified
|
|
if not services:
|
|
services = self._suggest_services(task)
|
|
|
|
# Extract keywords from task if not provided
|
|
if not keywords:
|
|
keywords = self._extract_keywords(task)
|
|
|
|
# Search each service
|
|
all_matches: list[FileMatch] = []
|
|
service_contexts = {}
|
|
|
|
for service_name in services:
|
|
service_info = self.project_index.get("services", {}).get(service_name)
|
|
if not service_info:
|
|
continue
|
|
|
|
service_path = Path(service_info.get("path", service_name))
|
|
if not service_path.is_absolute():
|
|
service_path = self.project_dir / service_path
|
|
|
|
# Search this service
|
|
matches = self._search_service(service_path, service_name, keywords)
|
|
all_matches.extend(matches)
|
|
|
|
# Load or generate service context
|
|
service_contexts[service_name] = self._get_service_context(
|
|
service_path, service_name, service_info
|
|
)
|
|
|
|
# Categorize matches
|
|
files_to_modify, files_to_reference = self._categorize_matches(all_matches, task)
|
|
|
|
# Discover patterns from reference files
|
|
patterns = self._discover_patterns(files_to_reference, keywords)
|
|
|
|
return TaskContext(
|
|
task_description=task,
|
|
scoped_services=services,
|
|
files_to_modify=[asdict(f) if isinstance(f, FileMatch) else f for f in files_to_modify],
|
|
files_to_reference=[asdict(f) if isinstance(f, FileMatch) else f for f in files_to_reference],
|
|
patterns_discovered=patterns,
|
|
service_contexts=service_contexts,
|
|
)
|
|
|
|
def _suggest_services(self, task: str) -> list[str]:
|
|
"""Suggest which services are relevant for a task."""
|
|
task_lower = task.lower()
|
|
services = self.project_index.get("services", {})
|
|
suggested = []
|
|
|
|
for service_name, service_info in services.items():
|
|
score = 0
|
|
name_lower = service_name.lower()
|
|
|
|
# Check if service name is mentioned
|
|
if name_lower in task_lower:
|
|
score += 10
|
|
|
|
# Check service type relevance
|
|
service_type = service_info.get("type", "")
|
|
if service_type == "backend" and any(kw in task_lower for kw in ["api", "endpoint", "route", "database", "model"]):
|
|
score += 5
|
|
if service_type == "frontend" and any(kw in task_lower for kw in ["ui", "component", "page", "button", "form"]):
|
|
score += 5
|
|
if service_type == "worker" and any(kw in task_lower for kw in ["job", "task", "queue", "background", "async"]):
|
|
score += 5
|
|
if service_type == "scraper" and any(kw in task_lower for kw in ["scrape", "crawl", "fetch", "parse"]):
|
|
score += 5
|
|
|
|
# Check framework relevance
|
|
framework = service_info.get("framework", "").lower()
|
|
if framework and framework in task_lower:
|
|
score += 3
|
|
|
|
if score > 0:
|
|
suggested.append((service_name, score))
|
|
|
|
# Sort by score and return top services
|
|
suggested.sort(key=lambda x: x[1], reverse=True)
|
|
|
|
if suggested:
|
|
return [s[0] for s in suggested[:3]] # Top 3
|
|
|
|
# Default: return first backend and first frontend
|
|
default = []
|
|
for name, info in services.items():
|
|
if info.get("type") == "backend" and "backend" not in [s for s in default]:
|
|
default.append(name)
|
|
elif info.get("type") == "frontend" and "frontend" not in [s for s in default]:
|
|
default.append(name)
|
|
return default[:2] if default else list(services.keys())[:2]
|
|
|
|
def _extract_keywords(self, task: str) -> list[str]:
|
|
"""Extract search keywords from task description."""
|
|
# Remove common words
|
|
stopwords = {
|
|
"a", "an", "the", "to", "for", "of", "in", "on", "at", "by", "with",
|
|
"and", "or", "but", "is", "are", "was", "were", "be", "been", "being",
|
|
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
"should", "may", "might", "must", "can", "this", "that", "these",
|
|
"those", "i", "you", "we", "they", "it", "add", "create", "make",
|
|
"implement", "build", "fix", "update", "change", "modify", "when",
|
|
"if", "then", "else", "new", "existing",
|
|
}
|
|
|
|
# Tokenize and filter
|
|
words = re.findall(r'\b[a-zA-Z_][a-zA-Z0-9_]*\b', task.lower())
|
|
keywords = [w for w in words if w not in stopwords and len(w) > 2]
|
|
|
|
# Deduplicate while preserving order
|
|
seen = set()
|
|
unique_keywords = []
|
|
for kw in keywords:
|
|
if kw not in seen:
|
|
seen.add(kw)
|
|
unique_keywords.append(kw)
|
|
|
|
return unique_keywords[:10] # Top 10 keywords
|
|
|
|
def _search_service(
|
|
self,
|
|
service_path: Path,
|
|
service_name: str,
|
|
keywords: list[str],
|
|
) -> list[FileMatch]:
|
|
"""Search a service for files matching keywords."""
|
|
matches = []
|
|
|
|
if not service_path.exists():
|
|
return matches
|
|
|
|
for file_path in self._iter_code_files(service_path):
|
|
try:
|
|
content = file_path.read_text(errors='ignore')
|
|
content_lower = content.lower()
|
|
|
|
# Score this file
|
|
score = 0
|
|
matching_keywords = []
|
|
matching_lines = []
|
|
|
|
for keyword in keywords:
|
|
if keyword in content_lower:
|
|
# Count occurrences
|
|
count = content_lower.count(keyword)
|
|
score += min(count, 10) # Cap at 10 per keyword
|
|
matching_keywords.append(keyword)
|
|
|
|
# Find matching lines (first 3 per keyword)
|
|
lines = content.split('\n')
|
|
found = 0
|
|
for i, line in enumerate(lines, 1):
|
|
if keyword in line.lower() and found < 3:
|
|
matching_lines.append((i, line.strip()[:100]))
|
|
found += 1
|
|
|
|
if score > 0:
|
|
rel_path = str(file_path.relative_to(self.project_dir))
|
|
matches.append(FileMatch(
|
|
path=rel_path,
|
|
service=service_name,
|
|
reason=f"Contains: {', '.join(matching_keywords)}",
|
|
relevance_score=score,
|
|
matching_lines=matching_lines[:5], # Top 5 lines
|
|
))
|
|
|
|
except (IOError, UnicodeDecodeError):
|
|
continue
|
|
|
|
# Sort by relevance
|
|
matches.sort(key=lambda m: m.relevance_score, reverse=True)
|
|
return matches[:20] # Top 20 per service
|
|
|
|
def _iter_code_files(self, directory: Path):
|
|
"""Iterate over code files in a directory."""
|
|
for item in directory.rglob("*"):
|
|
if item.is_file() and item.suffix in CODE_EXTENSIONS:
|
|
# Check if in skip directory
|
|
parts = item.relative_to(directory).parts
|
|
if not any(part in SKIP_DIRS for part in parts):
|
|
yield item
|
|
|
|
def _categorize_matches(
|
|
self,
|
|
matches: list[FileMatch],
|
|
task: str,
|
|
) -> tuple[list[FileMatch], list[FileMatch]]:
|
|
"""Categorize matches into files to modify vs reference."""
|
|
to_modify = []
|
|
to_reference = []
|
|
|
|
# Keywords that suggest modification
|
|
modify_keywords = ["add", "create", "implement", "fix", "update", "change", "modify", "new"]
|
|
task_lower = task.lower()
|
|
is_modification = any(kw in task_lower for kw in modify_keywords)
|
|
|
|
for match in matches:
|
|
# High relevance files in the "right" location are likely to be modified
|
|
path_lower = match.path.lower()
|
|
|
|
is_test = "test" in path_lower or "spec" in path_lower
|
|
is_example = "example" in path_lower or "sample" in path_lower
|
|
is_config = "config" in path_lower and match.relevance_score < 5
|
|
|
|
if is_test or is_example or is_config:
|
|
# Tests/examples are references
|
|
match.reason = f"Reference pattern: {match.reason}"
|
|
to_reference.append(match)
|
|
elif match.relevance_score >= 5 and is_modification:
|
|
# High relevance + modification task = likely to modify
|
|
match.reason = f"Likely to modify: {match.reason}"
|
|
to_modify.append(match)
|
|
else:
|
|
# Everything else is a reference
|
|
match.reason = f"Related: {match.reason}"
|
|
to_reference.append(match)
|
|
|
|
# Limit results
|
|
return to_modify[:10], to_reference[:15]
|
|
|
|
def _discover_patterns(
|
|
self,
|
|
reference_files: list[FileMatch],
|
|
keywords: list[str],
|
|
) -> dict[str, str]:
|
|
"""Discover code patterns from reference files."""
|
|
patterns = {}
|
|
|
|
for match in reference_files[:5]: # Analyze top 5 reference files
|
|
try:
|
|
file_path = self.project_dir / match.path
|
|
content = file_path.read_text(errors='ignore')
|
|
|
|
# Look for common patterns
|
|
for keyword in keywords:
|
|
if keyword in content.lower():
|
|
# Extract a snippet around the keyword
|
|
lines = content.split('\n')
|
|
for i, line in enumerate(lines):
|
|
if keyword in line.lower():
|
|
# Get context (3 lines before and after)
|
|
start = max(0, i - 3)
|
|
end = min(len(lines), i + 4)
|
|
snippet = '\n'.join(lines[start:end])
|
|
|
|
pattern_key = f"{keyword}_pattern"
|
|
if pattern_key not in patterns:
|
|
patterns[pattern_key] = f"From {match.path}:\n{snippet[:300]}"
|
|
break
|
|
|
|
except (IOError, UnicodeDecodeError):
|
|
continue
|
|
|
|
return patterns
|
|
|
|
def _get_service_context(
|
|
self,
|
|
service_path: Path,
|
|
service_name: str,
|
|
service_info: dict,
|
|
) -> dict:
|
|
"""Get or generate context for a service."""
|
|
# Check for SERVICE_CONTEXT.md
|
|
context_file = service_path / "SERVICE_CONTEXT.md"
|
|
if context_file.exists():
|
|
return {
|
|
"source": "SERVICE_CONTEXT.md",
|
|
"content": context_file.read_text()[:2000], # First 2000 chars
|
|
}
|
|
|
|
# Generate basic context from service info
|
|
return {
|
|
"source": "generated",
|
|
"language": service_info.get("language"),
|
|
"framework": service_info.get("framework"),
|
|
"type": service_info.get("type"),
|
|
"entry_point": service_info.get("entry_point"),
|
|
"key_directories": service_info.get("key_directories", {}),
|
|
}
|
|
|
|
|
|
def build_task_context(
|
|
project_dir: Path,
|
|
task: str,
|
|
services: list[str] | None = None,
|
|
keywords: list[str] | None = None,
|
|
output_file: Path | None = None,
|
|
) -> dict:
|
|
"""
|
|
Build context for a task and optionally save to file.
|
|
|
|
Args:
|
|
project_dir: Path to project root
|
|
task: Task description
|
|
services: Services to search (None = auto-detect)
|
|
keywords: Keywords to search for (None = extract from task)
|
|
output_file: Optional path to save JSON output
|
|
|
|
Returns:
|
|
Context as a dictionary
|
|
"""
|
|
builder = ContextBuilder(project_dir)
|
|
context = builder.build_context(task, services, keywords)
|
|
|
|
result = {
|
|
"task": context.task_description,
|
|
"scoped_services": context.scoped_services,
|
|
"files_to_modify": context.files_to_modify,
|
|
"files_to_reference": context.files_to_reference,
|
|
"patterns": context.patterns_discovered,
|
|
"service_contexts": context.service_contexts,
|
|
}
|
|
|
|
if output_file:
|
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(output_file, "w") as f:
|
|
json.dump(result, f, indent=2)
|
|
print(f"Task context saved to: {output_file}")
|
|
|
|
return result
|
|
|
|
|
|
def main():
|
|
"""CLI entry point."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Build task-specific context by searching the codebase"
|
|
)
|
|
parser.add_argument(
|
|
"--project-dir",
|
|
type=Path,
|
|
default=Path.cwd(),
|
|
help="Project directory (default: current directory)",
|
|
)
|
|
parser.add_argument(
|
|
"--task",
|
|
type=str,
|
|
required=True,
|
|
help="Description of the task",
|
|
)
|
|
parser.add_argument(
|
|
"--services",
|
|
type=str,
|
|
default=None,
|
|
help="Comma-separated list of services to search",
|
|
)
|
|
parser.add_argument(
|
|
"--keywords",
|
|
type=str,
|
|
default=None,
|
|
help="Comma-separated list of keywords to search for",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
default=None,
|
|
help="Output file for JSON results",
|
|
)
|
|
parser.add_argument(
|
|
"--quiet",
|
|
action="store_true",
|
|
help="Only output JSON, no status messages",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Parse comma-separated args
|
|
services = args.services.split(",") if args.services else None
|
|
keywords = args.keywords.split(",") if args.keywords else None
|
|
|
|
result = build_task_context(
|
|
args.project_dir,
|
|
args.task,
|
|
services,
|
|
keywords,
|
|
args.output,
|
|
)
|
|
|
|
if not args.quiet or not args.output:
|
|
print(json.dumps(result, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|