6a6247bbf2
* Fix UTF-8 encoding for Priorities 1-2 (Core & Agents - 18 instances) Add encoding="utf-8" to file operations in: - Priority 1: Core Infrastructure (8 instances) - core/progress.py (6 read operations) - core/debug.py (1 append operation) - core/workspace/setup.py (1 read operation) - Priority 2: Agent System (10 instances) - agents/utils.py (1 read) - agents/tools_pkg/tools/subtask.py (1 read, 1 write) - agents/tools_pkg/tools/memory.py (2 read, 1 write, 1 append) - agents/tools_pkg/tools/qa.py (1 read, 1 write) - agents/tools_pkg/tools/progress.py (1 read) All changes use double quotes for ruff format compliance. * Fix UTF-8 encoding for Priorities 3-4 (Spec & Project - 26 instances) Add encoding="utf-8" to file operations in: - Priority 3: Spec Pipeline (21 instances) - spec/context.py (4: 2 read, 2 write) - spec/complexity.py (3: 2 read, 1 write) - spec/requirements.py (3: 2 read, 1 write) - spec/validator.py (3 write operations) - spec/writer.py (2: 1 read, 1 write) - spec/discovery.py (1 read) - spec/pipeline/orchestrator.py (2 read) - spec/phases/requirements_phases.py (1 write) - spec/validate_pkg/auto_fix.py (2: 1 read, 1 write) - Priority 4: Project Analyzer (5 instances) - project/analyzer.py (2: 1 read, 1 write) - project/config_parser.py (2 read operations) - project/stack_detector.py (1 read) All changes use double quotes for ruff format compliance. * Fix UTF-8 encoding for Priorities 5-7 (Services, Analysis, Ideation - 43 instances) Add encoding="utf-8" to file operations in: - Priority 5: Services (12 instances) - services/recovery.py (8: 4 read, 4 write) - services/context.py (4 read operations) - Priority 6: Analysis & QA (6 instances) - analysis/analyzers/__init__.py (2 write) - analysis/insight_extractor.py (1 read) - qa/criteria.py (2: 1 read, 1 write) - qa/report.py (1 read) - Priority 7: Ideation & Roadmap (25 instances) - ideation/analyzer.py (3 read) - ideation/formatter.py (4 read, 1 write) - ideation/phase_executor.py (5: 3 read, 2 write) - ideation/runner.py (1 read) - runners/roadmap/competitor_analyzer.py (3: 1 read, 2 write) - runners/roadmap/graph_integration.py (3 write) - runners/roadmap/orchestrator.py (1 read) - runners/roadmap/phases.py (2 read) - runners/insights_runner.py (3 read) All changes use double quotes for ruff format compliance. * Fix UTF-8 encoding for Priorities 8-14 (All remaining - 85+ instances) Add encoding="utf-8" to file operations across all remaining modules: Priorities 8-10 (Merge, Memory, Integrations - 26 instances): - merge/ (4 files) - memory/ (3 files) - context/ (3 files) - integrations/ (4 files) Priorities 11-14 (GitHub, GitLab, AI, Other - 59 instances): - runners/github/ (19 files) - runners/gitlab/ (3 files) - runners/ai_analyzer/ (1 file) All changes use double quotes for ruff format compliance. Applied using Python regex script for efficiency. * Fix UTF-8 encoding for missed instances (23 instances) Fix remaining instances missed by batch script: - cli/batch_commands.py (3 instances) - cli/followup_commands.py (1 instance) - core/client.py (1 instance) - phase_config.py (1 instance) - planner_lib/context.py (4 instances) - prediction/main.py (1 instance) - prediction/memory_loader.py (1 instance) - prompts_pkg/prompts.py (2 instances) - review/formatters.py (1 instance) - review/state.py (2 instances) - spec/phases/spec_phases.py (1 instance) - spec/pipeline/models.py (1 instance) - spec/validate_pkg/validators/context_validator.py (1 instance) - spec/validate_pkg/validators/implementation_plan_validator.py (1 instance) - ui/status.py (2 instances) All encoding parameters use double quotes for ruff format compliance. Verified: 0 instances without encoding remain in source code. * Fix missed os.fdopen() calls and duplicate encoding bug Thorough verification found 3 additional issues: - runners/github/file_lock.py:462 - os.fdopen missing encoding - runners/github/trust.py:442 - os.fdopen missing encoding - runners/insights_runner.py:372 - duplicate encoding parameter All fixed. Final count: 251 instances with encoding="utf-8" * Fix missed Path.read_text() and Path.write_text() encoding (99 instances) Gemini Code Assist review found instances we missed: - Path.read_text() without encoding: 77 instances → fixed - Path.write_text() without encoding: 22 instances → fixed Total UTF-8 encoding fixes: 350 instances across codebase - open() operations: 251 instances - Path.read_text(): 98 instances - Path.write_text(): 30 instances All text file operations now explicitly use encoding="utf-8". Addresses feedback from PR #782 review. * Fix critical syntax errors from CodeRabbit review - Fix os.getpid() syntax error in core/workspace/models.py (2 instances) Changed: os.getpid(, encoding="utf-8") -> str(os.getpid()) - Fix json.dumps invalid encoding parameter (3 instances) json.dumps() doesn't accept encoding parameter Changed: json.dumps(data, encoding="utf-8") -> json.dumps(data) Files: runners/ai_analyzer/cache_manager.py, runners/github/test_file_lock.py - Fix tempfile.NamedTemporaryFile missing encoding Added encoding="utf-8" to spec/requirements.py:22 - Fix subprocess.run text=True to encoding Changed: text=True -> encoding="utf-8" in core/workspace/setup.py:375 All critical syntax errors from CodeRabbit review resolved. * Fix critical syntax errors in test_context_gatherer.py - Line 78: Move encoding="utf-8" outside of JS string content Changed: write_text("...encoding="utf-8"...") To: write_text("...", encoding="utf-8") - Line 102: Move encoding="utf-8" outside of JS string content Changed: write_text("...encoding="utf-8"...") To: write_text("...", encoding="utf-8") Fixes syntax errors where encoding parameter was incorrectly placed inside the JavaScript code string instead of as write_text() parameter. * Fix CodeRabbit issues: UnicodeDecodeError handling and trailing newlines - Add UnicodeDecodeError to exception handling in agents/utils.py and spec/validate_pkg/auto_fix.py - Fix trailing newline preservation in merge/file_merger.py (2 locations) - Add encoding parameter to atomic_write() in runners/github/file_lock.py These fixes ensure robust error handling for malformed UTF-8 files and preserve file formatting during merge operations. * Fix test fixture to use UTF-8 encoding consistently Update spec_file fixture in tests/conftest.py to write spec file with encoding="utf-8" to match how it's read in validators. This ensures consistency between test fixtures and production code. * Fix linting errors and security vulnerabilities from merge - Remove unused tree-sitter methods in semantic_analyzer.py that caused F821 undefined name errors - Fix regex injection vulnerability in bump-version.js by properly escaping all regex special characters - Add escapeRegex() function to prevent security issues when version string is used in RegExp constructor Resolves ruff linting failures and CodeQL security alerts. * Fix code formatting for ruff compliance Apply formatting fixes to meet line length requirements: - context/builder.py: Split long line with array slicing - planner_lib/context.py: Split long ternary expression - spec/requirements.py: Split long tempfile.NamedTemporaryFile call Resolves ruff format check failures. * Fix missing UTF-8 encoding in init.py gitignore operations Found by pre-commit hook testing in PR #795: - Line 96: Path.read_text() without encoding - Line 122: Path.write_text() without encoding These handle .gitignore file operations and could fail on Windows with special characters in gitignore comments or entries. Total fixes in PR #782: 253 instances (was 251, +2 from init.py) * Add pre-commit hook for UTF-8 encoding enforcement 1. Encoding Check Script (scripts/check_encoding.py): - Validates all file operations have encoding="utf-8" - Checks open(), Path.read_text(), Path.write_text() - Checks json.load/dump with open() - Allows binary mode without encoding - Windows-compatible emoji output with UTF-8 reconfiguration 2. Pre-commit Config (.pre-commit-config.yaml): - Added check-file-encoding hook for apps/backend/ - Runs automatically before commits - Scoped to backend Python files only 3. Tests (tests/test_check_encoding.py): - Comprehensive test coverage (10 tests, all passing) - Tests detection of missing encoding - Tests allowlist for binary files - Tests multiple issues in single file - Tests file type filtering Purpose: - Prevent regression of 251 UTF-8 encoding fixes from PR #782 - Catch missing encoding in new code during development - Fast feedback loop for developers Implementation Notes: - Hook scoped to apps/backend/ to avoid false positives in test code - Uses simple regex matching for speed - Compatible with existing pre-commit infrastructure - Already caught 6 real issues in apps/backend/core/progress.py Related: PR #782 - Fix Windows UTF-8 encoding errors * Address CodeRabbit and Gemini review feedback Fixes based on automated review comments: 1. Binary Mode Detection (Critical Fix): - Replaced brittle regex with robust pattern: r'["'][rwax+]*b[rwax+]*["']' - Now correctly detects all binary modes: rb, wb, ab, r+b, w+b, etc. - Prevents false positives on text mode 'w' without 'b' - Added comprehensive tests for wb, ab, and text w modes 2. Encoding Detection Robustness (Critical Fix): - Changed from 'encoding=' string match to word boundary regex: r'\bencoding\s*=' - Now handles encoding with spaces: encoding = "utf-8" - Prevents false matches of substrings containing 'encoding=' - Applied across all checks (open, read_text, write_text, json.load, json.dump) - Added test for spaces around equals sign 3. Test Coverage Improvements: - Added json.dump() with encoding test (passing case) - Added json.dump() without encoding test (failing case) - Fixed test assertions to match actual behavior (== 1 not == 2) - Added 6 new tests for improved binary/text mode coverage - Total tests increased from 10 to 16, all passing ✅ 4. Code Cleanup: - Removed unused pytest import (CodeQL warning) - Simplified check_files() to remove unused variable tracking All changes validated with comprehensive test suite (16/16 passing). Related: PR #795 review feedback from CodeRabbit and Gemini Code Assist * docs: Add UTF-8 encoding guidelines and Windows development guide 1. CONTRIBUTING.md: - Added concise file encoding section after Code Style - DO/DON'T examples for common file operations - Covers open(), Path methods, json operations - References PR #782 and windows-development.md 2. guides/windows-development.md (NEW): - Comprehensive Windows development guide - File encoding (cp1252 vs UTF-8 issue) - Line endings, path separators, shell commands - Development environment recommendations - Common pitfalls and solutions - Testing guidelines 3. .github/PULL_REQUEST_TEMPLATE.md: - Added encoding checklist item for Python PRs - Helps catch missing encoding during review 4. guides/README.md: - Added windows-development.md to guide index - Organized with CLI-USAGE and linux guides Purpose: Educate developers about UTF-8 encoding requirements to prevent regressions of the 251 encoding issues fixed in PR #782. Automated checking via pre-commit hooks (PR #795) + developer education ensures long-term Windows compatibility. Related: - PR #782: Fix Windows UTF-8 encoding errors (251 instances) - PR #795: Add pre-commit hooks for encoding enforcement * Address review comments from CodeRabbit and Gemini 1. Fix CONTRIBUTING.md markdown linting issues - Add blank lines around code blocks (MD031) - Add JSON write example with ensure_ascii=False (Gemini suggestion) 2. Fix guides/windows-development.md markdown linting (39 violations) - Rename duplicate headings: "The Problem"/"The Solution" → "Problem"/"Solution" (MD024) - Add blank lines around all code blocks (MD031) - Add language specifiers to code blocks (MD040) - Add blank lines before/after headings (MD022) - Wrap long lines to <=80 characters (MD013) - Add blank line before list (MD032) - Use Gemini's idiomatic line ending normalization pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix additional UTF-8 encoding issues and improve encoding check script - Add encoding="utf-8" to 5 files that were missing it: - cli/workspace_commands.py: read_text for worktree config - context/pattern_discovery.py: read_text with errors param - context/search.py: read_text with errors param - core/sentry.py: open for package.json version detection - core/workspace/setup.py: open for security profile JSON - Improve check_encoding.py script to reduce false positives: - Use negative lookbehind to exclude os.open(), urlopen(), etc. - Handle nested parentheses correctly when checking args - Skip self.method.read_text() calls (custom methods, not Path) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix missing UTF-8 encoding in locked_write() function Add encoding parameter to locked_write() async context manager and use it in os.fdopen() call. This fixes HIGH priority issue from PR review where locked_write() was missing UTF-8 encoding support, which could cause encoding errors on Windows when writing files with non-ASCII content. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add UnicodeDecodeError handling for file loading resilience Address CodeRabbit review feedback: - runner.py: Add UnicodeDecodeError to exception handling when loading batch files - trust.py: Add exception handling in get_state() and get_all_states() to gracefully handle corrupted state files instead of failing completely Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix atomic_write to handle binary mode correctly The atomic_write function was unconditionally passing encoding to os.fdopen, which would crash with ValueError if called with binary mode (e.g., 'wb'). Apply the same fix used in locked_write: only pass encoding for text modes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix run_git() call with invalid parameters in setup.py Remove capture_output and encoding kwargs from run_git() call - these parameters are already handled internally by run_git() and passing them causes TypeError since the function doesn't accept them. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix CodeQL warnings and potential double-newline bug - Remove unused is_path_call variables in check_encoding.py - Remove unused failed_count variable in check_encoding.py - Remove unused escapeRegex function in bump-version.js - Fix potential double-newline when adding imports in file_merger.py (strip trailing newlines from content_after before inserting) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix Ruff formatting: wrap long line in file_merger.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add UnicodeDecodeError handling to all JSON file loading Comprehensively add UnicodeDecodeError to exception handlers across the codebase to handle legacy-encoded or corrupted files gracefully: - 32+ locations now catch UnicodeDecodeError alongside OSError and json.JSONDecodeError - context/builder.py: Regenerate index on decode failure - planner_lib/context.py: Use empty dicts on decode failure - check_encoding.py: Handle OSError for unreadable files - cleanup.py: Handle decode errors in index pruning This ensures the codebase is robust against non-UTF-8 files that may exist from previous Windows runs with cp1252 encoding. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add explanatory comments to empty except clauses Address CodeQL notices about empty except clauses with just 'pass' by adding explanatory comments describing the intent. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix review issues from Andy's Auto Claude PR Review 1. [HIGH] Fix double-close bug in trust.py:449 - Remove try/except around os.fdopen since it takes ownership of fd - The with statement handles closing, no need for explicit os.close() 2. [LOW] Fix dead code in file_merger.py:87,159 - Simplify endswith check to just '\n' since content is already normalized to LF at that point 3. [LOW] Fix escaped backslash-n in test_context_gatherer.py:150 - Change "\n" (literal backslash-n) to "\n" (actual newline) 4. [LOW] Fix coder.md examples missing encoding parameter - Add encoding="utf-8" to read_text() and open() calls in examples Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: TamerineSky <TamerineSky@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
644 lines
20 KiB
Python
644 lines
20 KiB
Python
"""
|
|
Insight Extractor
|
|
=================
|
|
|
|
Automatically extracts structured insights from completed coding sessions.
|
|
Runs after each session to capture rich, actionable knowledge for Graphiti memory.
|
|
|
|
Uses the Claude Agent SDK (same as the rest of the system) for extraction.
|
|
Falls back to generic insights if extraction fails (never blocks the build).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Check for Claude SDK availability
|
|
try:
|
|
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|
|
|
SDK_AVAILABLE = True
|
|
except ImportError:
|
|
SDK_AVAILABLE = False
|
|
ClaudeAgentOptions = None
|
|
ClaudeSDKClient = None
|
|
|
|
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
|
|
|
# Default model for insight extraction (fast and cheap)
|
|
# Note: Using Haiku 4.5 for fast, cheap extraction. Haiku does not support
|
|
# extended thinking, so thinking_default is set to "none" in models.py
|
|
DEFAULT_EXTRACTION_MODEL = "claude-haiku-4-5-20251001"
|
|
|
|
# Maximum diff size to send to the LLM (avoid context limits)
|
|
MAX_DIFF_CHARS = 15000
|
|
|
|
# Maximum attempt history entries to include
|
|
MAX_ATTEMPTS_TO_INCLUDE = 3
|
|
|
|
|
|
def is_extraction_enabled() -> bool:
|
|
"""Check if insight extraction is enabled."""
|
|
# Extraction requires Claude SDK and authentication token
|
|
if not SDK_AVAILABLE:
|
|
return False
|
|
if not get_auth_token():
|
|
return False
|
|
enabled_str = os.environ.get("INSIGHT_EXTRACTION_ENABLED", "true").lower()
|
|
return enabled_str in ("true", "1", "yes")
|
|
|
|
|
|
def get_extraction_model() -> str:
|
|
"""Get the model to use for insight extraction."""
|
|
return os.environ.get("INSIGHT_EXTRACTOR_MODEL", DEFAULT_EXTRACTION_MODEL)
|
|
|
|
|
|
# =============================================================================
|
|
# Git Helpers
|
|
# =============================================================================
|
|
|
|
|
|
def get_session_diff(
|
|
project_dir: Path,
|
|
commit_before: str | None,
|
|
commit_after: str | None,
|
|
) -> str:
|
|
"""
|
|
Get the git diff between two commits.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
commit_before: Commit hash before session (or None)
|
|
commit_after: Commit hash after session (or None)
|
|
|
|
Returns:
|
|
Diff text (truncated if too large)
|
|
"""
|
|
if not commit_before or not commit_after:
|
|
return "(No commits to diff)"
|
|
|
|
if commit_before == commit_after:
|
|
return "(No changes - same commit)"
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "diff", commit_before, commit_after],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
diff = result.stdout
|
|
|
|
if len(diff) > MAX_DIFF_CHARS:
|
|
# Truncate and add note
|
|
diff = (
|
|
diff[:MAX_DIFF_CHARS] + f"\n\n... (truncated, {len(diff)} chars total)"
|
|
)
|
|
|
|
return diff if diff else "(Empty diff)"
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("Git diff timed out")
|
|
return "(Git diff timed out)"
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get git diff: {e}")
|
|
return f"(Failed to get diff: {e})"
|
|
|
|
|
|
def get_changed_files(
|
|
project_dir: Path,
|
|
commit_before: str | None,
|
|
commit_after: str | None,
|
|
) -> list[str]:
|
|
"""
|
|
Get list of files changed between two commits.
|
|
|
|
Args:
|
|
project_dir: Project root directory
|
|
commit_before: Commit hash before session
|
|
commit_after: Commit hash after session
|
|
|
|
Returns:
|
|
List of changed file paths
|
|
"""
|
|
if not commit_before or not commit_after or commit_before == commit_after:
|
|
return []
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "diff", "--name-only", commit_before, commit_after],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
|
return files
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get changed files: {e}")
|
|
return []
|
|
|
|
|
|
def get_commit_messages(
|
|
project_dir: Path,
|
|
commit_before: str | None,
|
|
commit_after: str | None,
|
|
) -> str:
|
|
"""Get commit messages between two commits."""
|
|
if not commit_before or not commit_after or commit_before == commit_after:
|
|
return "(No commits)"
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "log", "--oneline", f"{commit_before}..{commit_after}"],
|
|
cwd=project_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
return result.stdout.strip() if result.stdout.strip() else "(No commits)"
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get commit messages: {e}")
|
|
return f"(Failed: {e})"
|
|
|
|
|
|
# =============================================================================
|
|
# Input Gathering
|
|
# =============================================================================
|
|
|
|
|
|
def gather_extraction_inputs(
|
|
spec_dir: Path,
|
|
project_dir: Path,
|
|
subtask_id: str,
|
|
session_num: int,
|
|
commit_before: str | None,
|
|
commit_after: str | None,
|
|
success: bool,
|
|
recovery_manager: Any,
|
|
) -> dict:
|
|
"""
|
|
Gather all inputs needed for insight extraction.
|
|
|
|
Args:
|
|
spec_dir: Spec directory
|
|
project_dir: Project root
|
|
subtask_id: The subtask that was worked on
|
|
session_num: Session number
|
|
commit_before: Commit before session
|
|
commit_after: Commit after session
|
|
success: Whether session succeeded
|
|
recovery_manager: Recovery manager with attempt history
|
|
|
|
Returns:
|
|
Dict with all inputs for the extractor
|
|
"""
|
|
# Get subtask description from implementation plan
|
|
subtask_description = _get_subtask_description(spec_dir, subtask_id)
|
|
|
|
# Get git diff
|
|
diff = get_session_diff(project_dir, commit_before, commit_after)
|
|
|
|
# Get changed files
|
|
changed_files = get_changed_files(project_dir, commit_before, commit_after)
|
|
|
|
# Get commit messages
|
|
commit_messages = get_commit_messages(project_dir, commit_before, commit_after)
|
|
|
|
# Get attempt history
|
|
attempt_history = _get_attempt_history(recovery_manager, subtask_id)
|
|
|
|
return {
|
|
"subtask_id": subtask_id,
|
|
"subtask_description": subtask_description,
|
|
"session_num": session_num,
|
|
"success": success,
|
|
"diff": diff,
|
|
"changed_files": changed_files,
|
|
"commit_messages": commit_messages,
|
|
"attempt_history": attempt_history,
|
|
}
|
|
|
|
|
|
def _get_subtask_description(spec_dir: Path, subtask_id: str) -> str:
|
|
"""Get subtask description from implementation plan."""
|
|
plan_file = spec_dir / "implementation_plan.json"
|
|
if not plan_file.exists():
|
|
return f"Subtask: {subtask_id}"
|
|
|
|
try:
|
|
with open(plan_file, encoding="utf-8") as f:
|
|
plan = json.load(f)
|
|
|
|
# Search through phases for the subtask
|
|
for phase in plan.get("phases", []):
|
|
for subtask in phase.get("subtasks", []):
|
|
if subtask.get("id") == subtask_id:
|
|
return subtask.get("description", f"Subtask: {subtask_id}")
|
|
|
|
return f"Subtask: {subtask_id}"
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load subtask description: {e}")
|
|
return f"Subtask: {subtask_id}"
|
|
|
|
|
|
def _get_attempt_history(recovery_manager: Any, subtask_id: str) -> list[dict]:
|
|
"""Get previous attempt history for this subtask."""
|
|
if not recovery_manager:
|
|
return []
|
|
|
|
try:
|
|
history = recovery_manager.get_subtask_history(subtask_id)
|
|
attempts = history.get("attempts", [])
|
|
|
|
# Limit to recent attempts
|
|
return attempts[-MAX_ATTEMPTS_TO_INCLUDE:]
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get attempt history: {e}")
|
|
return []
|
|
|
|
|
|
# =============================================================================
|
|
# LLM Extraction
|
|
# =============================================================================
|
|
|
|
|
|
def _build_extraction_prompt(inputs: dict) -> str:
|
|
"""Build the prompt for insight extraction."""
|
|
prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md"
|
|
|
|
if prompt_file.exists():
|
|
base_prompt = prompt_file.read_text(encoding="utf-8")
|
|
else:
|
|
# Fallback if prompt file missing
|
|
base_prompt = """Extract structured insights from this coding session.
|
|
Output ONLY valid JSON with: file_insights, patterns_discovered, gotchas_discovered, approach_outcome, recommendations"""
|
|
|
|
# Build session context
|
|
session_context = f"""
|
|
---
|
|
|
|
## SESSION DATA
|
|
|
|
### Subtask
|
|
- **ID**: {inputs["subtask_id"]}
|
|
- **Description**: {inputs["subtask_description"]}
|
|
- **Session Number**: {inputs["session_num"]}
|
|
- **Outcome**: {"SUCCESS" if inputs["success"] else "FAILED"}
|
|
|
|
### Files Changed
|
|
{chr(10).join(f"- {f}" for f in inputs["changed_files"]) if inputs["changed_files"] else "(No files changed)"}
|
|
|
|
### Commit Messages
|
|
{inputs["commit_messages"]}
|
|
|
|
### Git Diff
|
|
```diff
|
|
{inputs["diff"]}
|
|
```
|
|
|
|
### Previous Attempts
|
|
{_format_attempt_history(inputs["attempt_history"])}
|
|
|
|
---
|
|
|
|
Now analyze this session and output ONLY the JSON object.
|
|
"""
|
|
|
|
return base_prompt + session_context
|
|
|
|
|
|
def _format_attempt_history(attempts: list[dict]) -> str:
|
|
"""Format attempt history for the prompt."""
|
|
if not attempts:
|
|
return "(First attempt - no previous history)"
|
|
|
|
lines = []
|
|
for i, attempt in enumerate(attempts, 1):
|
|
success = "SUCCESS" if attempt.get("success") else "FAILED"
|
|
approach = attempt.get("approach", "Unknown approach")
|
|
error = attempt.get("error", "")
|
|
lines.append(f"**Attempt {i}** ({success}): {approach}")
|
|
if error:
|
|
lines.append(f" Error: {error}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
async def run_insight_extraction(
|
|
inputs: dict, project_dir: Path | None = None
|
|
) -> dict | None:
|
|
"""
|
|
Run the insight extraction using Claude Agent SDK.
|
|
|
|
Args:
|
|
inputs: Gathered session inputs
|
|
project_dir: Project directory for SDK context (optional)
|
|
|
|
Returns:
|
|
Extracted insights dict or None if failed
|
|
"""
|
|
if not SDK_AVAILABLE:
|
|
logger.warning("Claude SDK not available, skipping insight extraction")
|
|
return None
|
|
|
|
if not get_auth_token():
|
|
logger.warning("No authentication token found, skipping insight extraction")
|
|
return None
|
|
|
|
# Ensure SDK can find the token
|
|
ensure_claude_code_oauth_token()
|
|
|
|
model = get_extraction_model()
|
|
prompt = _build_extraction_prompt(inputs)
|
|
|
|
# Use current directory if project_dir not specified
|
|
cwd = str(project_dir.resolve()) if project_dir else os.getcwd()
|
|
|
|
try:
|
|
# Use simple_client for insight extraction
|
|
from pathlib import Path
|
|
|
|
from core.simple_client import create_simple_client
|
|
|
|
client = create_simple_client(
|
|
agent_type="insights",
|
|
model=model,
|
|
system_prompt=(
|
|
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
|
"Always respond with valid JSON only, no markdown formatting or explanations."
|
|
),
|
|
cwd=Path(cwd) if cwd else None,
|
|
)
|
|
|
|
# Use async context manager
|
|
async with client:
|
|
await client.query(prompt)
|
|
|
|
# Collect the response
|
|
response_text = ""
|
|
message_count = 0
|
|
text_blocks_found = 0
|
|
|
|
async for msg in client.receive_response():
|
|
msg_type = type(msg).__name__
|
|
message_count += 1
|
|
|
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
|
for block in msg.content:
|
|
# Must check block type - only TextBlock has .text attribute
|
|
block_type = type(block).__name__
|
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
|
text_blocks_found += 1
|
|
if block.text: # Only add non-empty text
|
|
response_text += block.text
|
|
else:
|
|
logger.debug(
|
|
f"Found empty TextBlock in response (block #{text_blocks_found})"
|
|
)
|
|
|
|
# Log response collection summary
|
|
logger.debug(
|
|
f"Insight extraction response: {message_count} messages, "
|
|
f"{text_blocks_found} text blocks, {len(response_text)} chars collected"
|
|
)
|
|
|
|
# Validate we received content before parsing
|
|
if not response_text.strip():
|
|
logger.warning(
|
|
f"Insight extraction returned empty response. "
|
|
f"Messages received: {message_count}, TextBlocks found: {text_blocks_found}. "
|
|
f"This may indicate the AI model did not respond with text content."
|
|
)
|
|
return None
|
|
|
|
# Parse JSON from response
|
|
return parse_insights(response_text)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Insight extraction failed: {e}")
|
|
return None
|
|
|
|
|
|
def parse_insights(response_text: str) -> dict | None:
|
|
"""
|
|
Parse the LLM response into structured insights.
|
|
|
|
Args:
|
|
response_text: Raw LLM response
|
|
|
|
Returns:
|
|
Parsed insights dict or None if parsing failed
|
|
"""
|
|
# Try to extract JSON from the response
|
|
text = response_text.strip()
|
|
|
|
# Early validation - check for empty response
|
|
if not text:
|
|
logger.warning("Cannot parse insights: response text is empty")
|
|
return None
|
|
|
|
# Handle markdown code blocks
|
|
if text.startswith("```"):
|
|
# Remove code block markers
|
|
lines = text.split("\n")
|
|
# Remove first line (```json or ```)
|
|
if lines[0].startswith("```"):
|
|
lines = lines[1:]
|
|
# Remove last line if it's ```
|
|
if lines and lines[-1].strip() == "```":
|
|
lines = lines[:-1]
|
|
text = "\n".join(lines).strip()
|
|
|
|
# Check again after removing code blocks
|
|
if not text:
|
|
logger.warning(
|
|
"Cannot parse insights: response contained only markdown code block markers with no content"
|
|
)
|
|
return None
|
|
|
|
try:
|
|
insights = json.loads(text)
|
|
|
|
# Validate structure
|
|
if not isinstance(insights, dict):
|
|
logger.warning(
|
|
f"Insights is not a dict, got type: {type(insights).__name__}"
|
|
)
|
|
return None
|
|
|
|
# Ensure required keys exist with defaults
|
|
insights.setdefault("file_insights", [])
|
|
insights.setdefault("patterns_discovered", [])
|
|
insights.setdefault("gotchas_discovered", [])
|
|
insights.setdefault("approach_outcome", {})
|
|
insights.setdefault("recommendations", [])
|
|
|
|
return insights
|
|
|
|
except json.JSONDecodeError as e:
|
|
logger.warning(f"Failed to parse insights JSON: {e}")
|
|
# Show more context in the error message
|
|
preview_length = min(500, len(text))
|
|
logger.warning(
|
|
f"Response text preview (first {preview_length} chars): {text[:preview_length]}"
|
|
)
|
|
if len(text) > preview_length:
|
|
logger.warning(f"... (total length: {len(text)} chars)")
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# Main Entry Point
|
|
# =============================================================================
|
|
|
|
|
|
async def extract_session_insights(
|
|
spec_dir: Path,
|
|
project_dir: Path,
|
|
subtask_id: str,
|
|
session_num: int,
|
|
commit_before: str | None,
|
|
commit_after: str | None,
|
|
success: bool,
|
|
recovery_manager: Any,
|
|
) -> dict:
|
|
"""
|
|
Extract insights from a completed coding session.
|
|
|
|
This is the main entry point called from post_session_processing().
|
|
Falls back to generic insights if extraction fails.
|
|
|
|
Args:
|
|
spec_dir: Spec directory
|
|
project_dir: Project root
|
|
subtask_id: Subtask that was worked on
|
|
session_num: Session number
|
|
commit_before: Commit before session
|
|
commit_after: Commit after session
|
|
success: Whether session succeeded
|
|
recovery_manager: Recovery manager with attempt history
|
|
|
|
Returns:
|
|
Insights dict (rich if extraction succeeded, generic if failed)
|
|
"""
|
|
# Check if extraction is enabled
|
|
if not is_extraction_enabled():
|
|
logger.info("Insight extraction disabled")
|
|
return _get_generic_insights(subtask_id, success)
|
|
|
|
# Check for no changes
|
|
if commit_before == commit_after:
|
|
logger.info("No changes to extract insights from")
|
|
return _get_generic_insights(subtask_id, success)
|
|
|
|
try:
|
|
# Gather inputs
|
|
inputs = gather_extraction_inputs(
|
|
spec_dir=spec_dir,
|
|
project_dir=project_dir,
|
|
subtask_id=subtask_id,
|
|
session_num=session_num,
|
|
commit_before=commit_before,
|
|
commit_after=commit_after,
|
|
success=success,
|
|
recovery_manager=recovery_manager,
|
|
)
|
|
|
|
# Run extraction
|
|
extracted = await run_insight_extraction(inputs, project_dir=project_dir)
|
|
|
|
if extracted:
|
|
# Add metadata
|
|
extracted["subtask_id"] = subtask_id
|
|
extracted["session_num"] = session_num
|
|
extracted["success"] = success
|
|
extracted["changed_files"] = inputs["changed_files"]
|
|
|
|
logger.info(
|
|
f"Extracted insights: {len(extracted.get('file_insights', []))} file insights, "
|
|
f"{len(extracted.get('patterns_discovered', []))} patterns, "
|
|
f"{len(extracted.get('gotchas_discovered', []))} gotchas"
|
|
)
|
|
return extracted
|
|
else:
|
|
logger.warning("Extraction returned no results, using generic insights")
|
|
return _get_generic_insights(subtask_id, success)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Insight extraction failed: {e}, using generic insights")
|
|
return _get_generic_insights(subtask_id, success)
|
|
|
|
|
|
def _get_generic_insights(subtask_id: str, success: bool) -> dict:
|
|
"""Return generic insights when extraction fails or is disabled."""
|
|
return {
|
|
"file_insights": [],
|
|
"patterns_discovered": [],
|
|
"gotchas_discovered": [],
|
|
"approach_outcome": {
|
|
"success": success,
|
|
"approach_used": f"Implemented subtask: {subtask_id}",
|
|
"why_it_worked": None,
|
|
"why_it_failed": None,
|
|
"alternatives_tried": [],
|
|
},
|
|
"recommendations": [],
|
|
"subtask_id": subtask_id,
|
|
"success": success,
|
|
"changed_files": [],
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# CLI for Testing
|
|
# =============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
import asyncio
|
|
|
|
parser = argparse.ArgumentParser(description="Test insight extraction")
|
|
parser.add_argument("--spec-dir", type=Path, required=True, help="Spec directory")
|
|
parser.add_argument(
|
|
"--project-dir", type=Path, required=True, help="Project directory"
|
|
)
|
|
parser.add_argument(
|
|
"--commit-before", type=str, required=True, help="Commit before session"
|
|
)
|
|
parser.add_argument(
|
|
"--commit-after", type=str, required=True, help="Commit after session"
|
|
)
|
|
parser.add_argument(
|
|
"--subtask-id", type=str, default="test-subtask", help="Subtask ID"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
async def main():
|
|
insights = await extract_session_insights(
|
|
spec_dir=args.spec_dir,
|
|
project_dir=args.project_dir,
|
|
subtask_id=args.subtask_id,
|
|
session_num=1,
|
|
commit_before=args.commit_before,
|
|
commit_after=args.commit_after,
|
|
success=True,
|
|
recovery_manager=None,
|
|
)
|
|
print(json.dumps(insights, indent=2))
|
|
|
|
asyncio.run(main())
|