diff --git a/CLAUDE.md b/CLAUDE.md index c561a2a..81fdcf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ CLI (`vit` command) → vit-core (Python) → Git (system binary) [power u | Language | Python 3.x | | Version control | System `git` binary | | Data format | JSON (`indent=2, sort_keys=True`) | -| AI merge | Gemini API (`google-generativeai`) | +| AI merge | Gemini API (`google-generativeai`) or OpenAI-compatible API (`openai`) | | Terminal | `rich` | --- diff --git a/docs/AI_MERGE_DETAILS.md b/docs/AI_MERGE_DETAILS.md index 0961876..9f48290 100644 --- a/docs/AI_MERGE_DETAILS.md +++ b/docs/AI_MERGE_DETAILS.md @@ -81,9 +81,44 @@ Return the resolved JSON for each domain file. """ ``` +## LLM Provider Support + +Vit supports multiple LLM providers: + +| Provider | Setup | Best For | +|----------|-------|----------| +| **Gemini** | `GEMINI_API_KEY=your_key` | Default, no extra dependencies | +| **OpenAI-compatible** | `VIT_LLM_URL=` `VIT_LLM_MODEL=` | Ollama (local), OpenAI, OpenRouter, Together AI, Groq, Azure | + +### Configuration Examples + +```bash +# Gemini (default) +export GEMINI_API_KEY=your_key_here + +# Ollama (local) +export VIT_LLM_URL=http://localhost:11434/v1 +export VIT_LLM_MODEL=qwen2.5-coder:14b + +# OpenAI +export VIT_LLM_URL=https://api.openai.com/v1 +export VIT_LLM_MODEL=gpt-4 +export GEMINI_API_KEY=your_openai_key # Uses same env var for API key + +# OpenRouter +export VIT_LLM_URL=https://openrouter.ai/api/v1 +export VIT_LLM_MODEL=anthropic/claude-3.5-sonnet +export GEMINI_API_KEY=your_openrouter_key +``` + +### Requirements + +- **Gemini**: `pip install google-generativeai` +- **OpenAI-compatible**: `pip install openai` + ## Implementation Notes -- Uses Gemini API via `google-generativeai` Python SDK +- Uses Gemini API via `google-generativeai` Python SDK, or any OpenAI-compatible API via `openai` Python SDK - Called only when git can't merge cleanly OR post-merge validation finds issues - For common case (different domains, no cross-references), AI is never invoked - User always sees what the AI changed before commit diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..dc2bdaa --- /dev/null +++ b/examples/README.md @@ -0,0 +1,119 @@ +# Vit Examples + +This directory contains example scripts and sample data for testing vit functionality. + +## LLM Demo (`llm_demo.py`) + +A standalone demo script that tests AI merge resolution with sample timeline data. No Resolve project or git repository needed. + +### What it does + +The demo simulates a realistic merge scenario: +- **BASE**: A timeline with an interview clip and B-roll +- **OURS (Editor)**: Trimmed the interview and added new B-roll footage +- **THEIRS (Colorist)**: Color graded the interview with warmer tones, adjusted audio + +The LLM analyzes these changes and decides how to merge them. + +### Supported LLM Providers + +The demo works with any OpenAI-compatible API: + +| Provider | URL Example | Notes | +|----------|-------------|-------| +| **Ollama** (local) | `http://localhost:11434/v1` | Free, runs locally | +| **OpenAI** | `https://api.openai.com/v1` | GPT-4, GPT-3.5 | +| **OpenRouter** | `https://openrouter.ai/api/v1` | Access many models | +| **Together AI** | `https://api.together.xyz/v1` | Various open models | +| **Groq** | `https://api.groq.com/openai/v1` | Fast inference | +| **Azure OpenAI** | `https://your-resource.openai.azure.com/openai/deployments/your-deployment` | Enterprise | + +### Usage + +#### With Ollama (Local) + +1. Install Ollama: https://ollama.com + +2. Pull a good coding model: + ```bash + ollama pull qwen2.5-coder:14b + ``` + +3. Run the demo: + ```bash + export VIT_LLM_URL=http://localhost:11434/v1 + export VIT_LLM_MODEL=qwen2.5-coder:14b + python examples/llm_demo.py + ``` + +#### With OpenAI + +```bash +export VIT_LLM_URL=https://api.openai.com/v1 +export VIT_LLM_MODEL=gpt-4 +export GEMINI_API_KEY=your_openai_key_here # Reuses same env var for API key +python examples/llm_demo.py +``` + +#### With OpenRouter + +```bash +export VIT_LLM_URL=https://openrouter.ai/api/v1 +export VIT_LLM_MODEL=anthropic/claude-3.5-sonnet +export GEMINI_API_KEY=your_openrouter_key_here +python examples/llm_demo.py +``` + +#### With Gemini + +```bash +export GEMINI_API_KEY=your_gemini_key_here +python examples/llm_demo.py +``` + +### Expected Output + +The LLM will analyze the merge and output something like: + +``` +============================================================ +AI MERGE ANALYSIS RESULT +============================================================ + +Summary: Editor trimmed clips and added B-roll; Colorist graded interview + +Decisions: + [✓] cuts: accept_ours + Confidence: high + Reasoning: Editor made structural changes to timeline + + [~] color: merge + Confidence: medium + Reasoning: Colorist graded item_001; editor's new clip item_003 has no grade + + [?] audio: needs_user_input + Confidence: low + Reasoning: Both branches modified audio (trim vs volume) + Options: + A) Keep ours: Trimmed audio to match video + B) Keep theirs: Reduced volume with original length + C) Merge: Apply both trim and volume reduction + +Auto-resolved domains: + - cuts + - color + +⚠ This merge requires user input for some decisions. +``` + +### Sample Data Structure + +The demo uses the same JSON structure as real vit projects: + +- `cuts.json` - Video clip placements, in/out points, transforms +- `color.json` - Color grades per clip +- `audio.json` - Audio levels and panning +- `markers.json` - Timeline markers +- `metadata.json` - Project settings + +See `docs/JSON_SCHEMAS.md` for full schema documentation. diff --git a/examples/llm_demo.py b/examples/llm_demo.py new file mode 100644 index 0000000..f074a43 --- /dev/null +++ b/examples/llm_demo.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +"""Demo script to test LLM merge resolution with sample timeline data. + +This script demonstrates the AI merge functionality using sample timeline data +without requiring a full vit project or DaVinci Resolve. + +Works with any OpenAI-compatible API: +- Local: Ollama, LM Studio, etc. +- Hosted: OpenAI, OpenRouter, Together AI, Groq, Azure, etc. +- Or use Google's Gemini API directly + +Usage: + # With Ollama (local) + export VIT_LLM_URL=http://localhost:11434/v1 + export VIT_LLM_MODEL=qwen2.5-coder:14b + python llm_demo.py + + # With OpenAI + export VIT_LLM_URL=https://api.openai.com/v1 + export VIT_LLM_MODEL=gpt-4 + export GEMINI_API_KEY=your_openai_key + python llm_demo.py + + # With OpenRouter + export VIT_LLM_URL=https://openrouter.ai/api/v1 + export VIT_LLM_MODEL=anthropic/claude-3.5-sonnet + export GEMINI_API_KEY=your_openrouter_key + python llm_demo.py + + # With Gemini + export GEMINI_API_KEY=your_gemini_key + python llm_demo.py +""" + +import json +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from vit.ai_merge import ( + load_llm_config, + ai_analyze_merge, + LLMConfig, +) +from vit.validator import ValidationIssue + + +# Sample timeline data representing a realistic merge scenario +SAMPLE_BASE = { + "cuts": { + "video_tracks": [ + { + "index": 1, + "items": [ + { + "id": "item_001", + "name": "Interview_A", + "media_ref": "sha256:abc123", + "record_start_frame": 0, + "record_end_frame": 720, + "source_start_frame": 0, + "source_end_frame": 720, + "track_index": 1, + "transform": {"Pan": 0.0, "Tilt": 0.0, "ZoomX": 1.0, "ZoomY": 1.0, "Opacity": 100.0} + }, + { + "id": "item_002", + "name": "B_Roll_City", + "media_ref": "sha256:def456", + "record_start_frame": 720, + "record_end_frame": 1440, + "source_start_frame": 0, + "source_end_frame": 720, + "track_index": 1, + "transform": {"Pan": 0.0, "Tilt": 0.0, "ZoomX": 1.0, "ZoomY": 1.0, "Opacity": 100.0} + } + ] + } + ] + }, + "color": { + "grades": { + "item_001": { + "num_nodes": 1, + "nodes": [{"index": 1, "label": "Corrector", "lut": ""}], + "saturation": 1.0, + "contrast": 1.0 + }, + "item_002": { + "num_nodes": 1, + "nodes": [{"index": 1, "label": "Corrector", "lut": ""}], + "saturation": 1.0, + "contrast": 1.0 + } + } + }, + "audio": { + "audio_tracks": [ + { + "index": 1, + "items": [ + { + "id": "audio_001", + "media_ref": "sha256:abc123", + "start_frame": 0, + "end_frame": 720, + "volume": 0.0, + "pan": 0.0 + }, + { + "id": "audio_002", + "media_ref": "sha256:def456", + "start_frame": 720, + "end_frame": 1440, + "volume": 0.0, + "pan": 0.0 + } + ] + } + ] + }, + "markers": {"markers": []}, + "metadata": { + "project_name": "Documentary", + "timeline_name": "Main Edit", + "frame_rate": 24.0, + "resolution": {"width": 1920, "height": 1080}, + "track_count": {"video": 1, "audio": 1} + } +} + + +# "OURS" branch: Editor trimmed the interview and added a new B-roll clip +SAMPLE_OURS = { + "cuts": { + "video_tracks": [ + { + "index": 1, + "items": [ + { + "id": "item_001", + "name": "Interview_A", + "media_ref": "sha256:abc123", + "record_start_frame": 0, + "record_end_frame": 600, # Trimmed shorter + "source_start_frame": 0, + "source_end_frame": 600, + "track_index": 1, + "transform": {"Pan": 0.0, "Tilt": 0.0, "ZoomX": 1.0, "ZoomY": 1.0, "Opacity": 100.0} + }, + { + "id": "item_002", + "name": "B_Roll_City", + "media_ref": "sha256:def456", + "record_start_frame": 600, + "record_end_frame": 1320, + "source_start_frame": 0, + "source_end_frame": 720, + "track_index": 1, + "transform": {"Pan": 0.0, "Tilt": 0.0, "ZoomX": 1.0, "ZoomY": 1.0, "Opacity": 100.0} + }, + { + "id": "item_003", # Added new clip + "name": "B_Roll_Harbor", + "media_ref": "sha256:ghi789", + "record_start_frame": 1320, + "record_end_frame": 1800, + "source_start_frame": 0, + "source_end_frame": 480, + "track_index": 1, + "transform": {"Pan": 0.0, "Tilt": 0.0, "ZoomX": 1.0, "ZoomY": 1.0, "Opacity": 100.0} + } + ] + } + ] + }, + "color": { + "grades": { + "item_001": { + "num_nodes": 1, + "nodes": [{"index": 1, "label": "Corrector", "lut": ""}], + "saturation": 1.0, + "contrast": 1.0 + }, + "item_002": { + "num_nodes": 1, + "nodes": [{"index": 1, "label": "Corrector", "lut": ""}], + "saturation": 1.0, + "contrast": 1.0 + } + # Note: item_003 has no grade yet + } + }, + "audio": { + "audio_tracks": [ + { + "index": 1, + "items": [ + { + "id": "audio_001", + "media_ref": "sha256:abc123", + "start_frame": 0, + "end_frame": 600, # Trimmed to match video + "volume": 0.0, + "pan": 0.0 + }, + { + "id": "audio_002", + "media_ref": "sha256:def456", + "start_frame": 600, + "end_frame": 1320, + "volume": 0.0, + "pan": 0.0 + } + ] + } + ] + }, + "markers": {"markers": []}, + "metadata": { + "project_name": "Documentary", + "timeline_name": "Main Edit", + "frame_rate": 24.0, + "resolution": {"width": 1920, "height": 1080}, + "track_count": {"video": 1, "audio": 1} + } +} + + +# "THEIRS" branch: Colorist graded the interview with warmer tones +SAMPLE_THEIRS = { + "cuts": { + "video_tracks": [ + { + "index": 1, + "items": [ + { + "id": "item_001", + "name": "Interview_A", + "media_ref": "sha256:abc123", + "record_start_frame": 0, + "record_end_frame": 720, + "source_start_frame": 0, + "source_end_frame": 720, + "track_index": 1, + "transform": {"Pan": 0.0, "Tilt": 0.0, "ZoomX": 1.0, "ZoomY": 1.0, "Opacity": 100.0} + }, + { + "id": "item_002", + "name": "B_Roll_City", + "media_ref": "sha256:def456", + "record_start_frame": 720, + "record_end_frame": 1440, + "source_start_frame": 0, + "source_end_frame": 720, + "track_index": 1, + "transform": {"Pan": 0.0, "Tilt": 0.0, "ZoomX": 1.0, "ZoomY": 1.0, "Opacity": 100.0} + } + ] + } + ] + }, + "color": { + "grades": { + "item_001": { + "num_nodes": 2, + "nodes": [ + {"index": 1, "label": "Corrector", "lut": ""}, + {"index": 2, "label": "Warm", "lut": ""} + ], + "saturation": 1.2, # Boosted saturation + "contrast": 1.1, + "temperature": 5500, + "tint": 5 + }, + "item_002": { + "num_nodes": 1, + "nodes": [{"index": 1, "label": "Corrector", "lut": ""}], + "saturation": 1.0, + "contrast": 1.0 + } + } + }, + "audio": { + "audio_tracks": [ + { + "index": 1, + "items": [ + { + "id": "audio_001", + "media_ref": "sha256:abc123", + "start_frame": 0, + "end_frame": 720, + "volume": -2.0, # Reduced volume + "pan": 0.0 + }, + { + "id": "audio_002", + "media_ref": "sha256:def456", + "start_frame": 720, + "end_frame": 1440, + "volume": 0.0, + "pan": 0.0 + } + ] + } + ] + }, + "markers": { + "markers": [ + { + "frame": 240, + "color": "Blue", + "name": "Color review", + "note": "Check skin tones", + "duration": 1 + } + ] + }, + "metadata": { + "project_name": "Documentary", + "timeline_name": "Main Edit", + "frame_rate": 24.0, + "resolution": {"width": 1920, "height": 1080}, + "track_count": {"video": 1, "audio": 1} + } +} + + +def print_scenario(): + """Print the merge scenario description.""" + print("=" * 60) + print("VIT AI MERGE DEMO") + print("=" * 60) + print() + print("Scenario: Editor and Colorist working in parallel") + print() + print("BASE (common ancestor):") + print(" - Interview clip (item_001): 0-720 frames") + print(" - B-roll clip (item_002): 720-1440 frames") + print() + print("OURS (Editor's branch):") + print(" - Trimmed interview to 0-600 frames") + print(" - Added new B-roll (item_003): 1320-1800 frames") + print(" - Audio trimmed to match") + print() + print("THEIRS (Colorist's branch):") + print(" - Color graded interview with warmer tones") + print(" - Boosted saturation to 1.2") + print(" - Reduced interview audio volume to -2dB") + print(" - Added marker for color review") + print() + print("Expected conflicts:") + print(" - Cuts: Editor added/trimmed clips") + print(" - Color: Colorist graded (but item_003 has no grade)") + print(" - Audio: Both modified (trim + volume)") + print() + + +def print_config(): + """Print current LLM configuration.""" + config = load_llm_config() + print("-" * 60) + print("LLM Configuration:") + print("-" * 60) + if config.provider == "openai": + print(f" Provider: OpenAI-compatible API") + print(f" URL: {config.base_url}") + print(f" Model: {config.model}") + else: + print(f" Provider: Gemini") + key_display = "***" if config.api_key else "(not set)" + print(f" API Key: {key_display}") + print() + + +def main(): + print_scenario() + print_config() + + # Check configuration + config = load_llm_config() + if config.provider == "gemini" and not config.api_key: + print("ERROR: No LLM configured!") + print() + print("Options:") + print() + print("1. Use any OpenAI-compatible API (Ollama, OpenAI, OpenRouter, etc.):") + print(" export VIT_LLM_URL=http://localhost:11434/v1 # or your API URL") + print(" export VIT_LLM_MODEL=qwen2.5-coder:14b # or your model") + print(" export GEMINI_API_KEY=your_api_key # if required") + print() + print("2. Use Gemini:") + print(" export GEMINI_API_KEY=your_key_here") + print() + sys.exit(1) + + print("Sending merge analysis request to LLM...") + if config.provider == "local": + print("(This can take a while)") + print() + + try: + analysis = ai_analyze_merge( + base_files=SAMPLE_BASE, + ours_files=SAMPLE_OURS, + theirs_files=SAMPLE_THEIRS, + issues=[], # No pre-detected validation issues + conflicted_files=[] # No git-level conflicts + ) + + if analysis is None: + print("ERROR: AI analysis failed. Check your LLM configuration.") + sys.exit(1) + + print("=" * 60) + print("AI MERGE ANALYSIS RESULT") + print("=" * 60) + print() + print(f"Summary: {analysis.summary}") + print() + + print("Decisions:") + for decision in analysis.decisions: + icon = {"high": "✓", "medium": "~", "low": "?"}.get(decision.confidence, "?") + print(f" [{icon}] {decision.domain}: {decision.action}") + print(f" Confidence: {decision.confidence}") + print(f" Reasoning: {decision.reasoning}") + if decision.options: + print(f" Options:") + for opt in decision.options: + print(f" {opt.key}) {opt.label}: {opt.description}") + print() + + if analysis.resolved: + print("Auto-resolved domains:") + for domain in analysis.resolved.keys(): + print(f" - {domain}") + print() + + if analysis.needs_user_input(): + print("⚠ This merge requires user input for some decisions.") + else: + print("✓ All decisions can be auto-resolved!") + + except Exception as e: + print(f"ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_ai_merge.py b/tests/test_ai_merge.py index 6968d22..db11f61 100644 --- a/tests/test_ai_merge.py +++ b/tests/test_ai_merge.py @@ -8,12 +8,14 @@ import pytest from vit.ai_merge import ( + LLMConfig, MergeAnalysis, MergeDecision, MergeOption, _build_analysis_prompt, _build_clarification_prompt, _extract_json_from_response, + _get_llm_model, ai_analyze_merge, ai_resolve_clarifications, ) @@ -260,7 +262,7 @@ def test_no_changes(self): class TestAiAnalyzeMerge: - @patch("vit.ai_merge._get_genai_model") + @patch("vit.ai_merge._get_llm_model") def test_successful_analysis(self, mock_get_model): mock_model = MagicMock() mock_response = MagicMock() @@ -281,7 +283,7 @@ def test_successful_analysis(self, mock_get_model): assert len(result.decisions) == 1 assert result.decisions[0].domain == "cuts" - @patch("vit.ai_merge._get_genai_model") + @patch("vit.ai_merge._get_llm_model") def test_invalid_json_returns_none(self, mock_get_model): mock_model = MagicMock() mock_response = MagicMock() @@ -292,7 +294,7 @@ def test_invalid_json_returns_none(self, mock_get_model): result = ai_analyze_merge({}, {}, {}, [], []) assert result is None - @patch("vit.ai_merge._get_genai_model") + @patch("vit.ai_merge._get_llm_model") def test_api_error_returns_none(self, mock_get_model): mock_get_model.side_effect = ValueError("No API key") @@ -301,7 +303,7 @@ def test_api_error_returns_none(self, mock_get_model): class TestAiResolveClarifications: - @patch("vit.ai_merge._get_genai_model") + @patch("vit.ai_merge._get_llm_model") def test_successful_clarification(self, mock_get_model): mock_model = MagicMock() mock_response = MagicMock() @@ -330,7 +332,7 @@ def test_successful_clarification(self, mock_get_model): assert result is not None assert "color" in result - @patch("vit.ai_merge._get_genai_model") + @patch("vit.ai_merge._get_llm_model") def test_no_questions_returns_empty(self, mock_get_model): analysis = MergeAnalysis( summary="Test", @@ -347,7 +349,7 @@ def test_no_questions_returns_empty(self, mock_get_model): class TestIntegration: """Integration tests with mocked Gemini API.""" - @patch("vit.ai_merge._get_genai_model") + @patch("vit.ai_merge._get_llm_model") def test_full_auto_resolve_flow(self, mock_get_model): """Test a merge where AI auto-resolves everything (no user input needed).""" mock_model = MagicMock() @@ -376,7 +378,7 @@ def test_full_auto_resolve_flow(self, mock_get_model): assert not analysis.needs_user_input() assert len(analysis.resolved) == 2 - @patch("vit.ai_merge._get_genai_model") + @patch("vit.ai_merge._get_llm_model") def test_mixed_flow_with_questions(self, mock_get_model): """Test a merge where some domains need user input.""" mock_model = MagicMock() diff --git a/vit/ai_merge.py b/vit/ai_merge.py index 55304db..1737aee 100644 --- a/vit/ai_merge.py +++ b/vit/ai_merge.py @@ -1,4 +1,4 @@ -"""AI-powered semantic merge resolution using Gemini API.""" +"""AI-powered semantic merge resolution using Gemini API or local LLM.""" import json import os @@ -92,6 +92,15 @@ def from_dict(cls, data: dict) -> "MergeAnalysis": ) +@dataclass +class LLMConfig: + """Configuration for LLM - either Gemini or OpenAI-compatible API.""" + provider: str # "gemini" or "openai" + api_key: Optional[str] = None # For Gemini or OpenAI-compatible services + base_url: Optional[str] = None # For OpenAI-compatible API (e.g., Ollama, OpenRouter, etc.) + model: Optional[str] = None # Model name for OpenAI-compatible API + + MERGE_ANALYSIS_SYSTEM_PROMPT = """\ You are a video editing timeline merge analyzer. You analyze merge conflicts \ and cross-domain semantic issues in vit timeline files, then produce structured \ @@ -282,36 +291,134 @@ def _load_api_key() -> Optional[str]: for line in f: line = line.strip() if line.startswith("GEMINI_API_KEY="): - return line.split("=", 1)[1].strip().strip("'\"") + return line.split("=", 1)[1].strip().strip('"') return None -def _extract_json_from_response(content: str) -> dict: - """Extract JSON from LLM response, handling markdown code blocks.""" - if "```json" in content: - content = content.split("```json")[1].split("```")[0] - elif "```" in content: - content = content.split("```")[1].split("```")[0] - return json.loads(content.strip()) +def _load_env_value(key: str) -> Optional[str]: + """Load a value from environment or .env file.""" + value = os.environ.get(key) + if value: + return value + from .core import find_project_root + root = find_project_root() + if root: + env_path = os.path.join(root, ".env") + if os.path.exists(env_path): + with open(env_path) as f: + for line in f: + line = line.strip() + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"') -def _get_genai_model(system_prompt: str): - """Get a configured Gemini model, handling import and API key setup.""" + return None + + +def load_llm_config() -> LLMConfig: + """Load LLM configuration from environment or .env file. + + Priority: + 1. If VIT_LLM_URL is set, use OpenAI-compatible API (Ollama, OpenRouter, etc.) + 2. If GEMINI_API_KEY is set, use Gemini + 3. Default to Gemini (will fail gracefully if no key) + """ + openai_url = _load_env_value("VIT_LLM_URL") + openai_model = _load_env_value("VIT_LLM_MODEL") or "qwen2.5-coder:14b" + gemini_key = _load_api_key() + + if openai_url: + return LLMConfig( + provider="openai", + base_url=openai_url, + model=openai_model + ) + + return LLMConfig( + provider="gemini", + api_key=gemini_key + ) + + +class LocalLLMModel: + """Wrapper for local LLM using OpenAI-compatible API.""" + + def __init__(self, client, model: str, system_instruction: str): + self.client = client + self.model = model + self.system_instruction = system_instruction + + def generate_content(self, prompt: str): + """Generate content using local LLM.""" + messages = [ + {"role": "system", "content": self.system_instruction}, + {"role": "user", "content": prompt} + ] + + response = self.client.chat.completions.create( + model=self.model, + messages=messages, + temperature=0.3, # Lower temperature for more deterministic JSON + max_tokens=8192 + ) + + # Return an object with .text attribute to match Gemini interface + class ResponseWrapper: + def __init__(self, text): + self.text = text + + return ResponseWrapper(response.choices[0].message.content) + + +def _get_llm_model(system_prompt: str): + """Get a configured LLM model (Gemini or OpenAI-compatible API). + + Returns a model object with a generate_content() method. + """ + config = load_llm_config() + + if config.provider == "openai": + try: + from openai import OpenAI + except ImportError: + raise ImportError( + "'openai' package not installed. Run: pip install openai" + ) + + if not config.base_url: + raise ValueError( + "VIT_LLM_URL not set. Add it to .env or set the environment variable.\n" + "Example: VIT_LLM_URL=http://localhost:11434/v1" + ) + + client = OpenAI( + base_url=config.base_url, + api_key=config.api_key or "not-needed" + ) + + return LocalLLMModel( + client=client, + model=config.model or "qwen2.5-coder:14b", + system_instruction=system_prompt + ) + + # Default to Gemini try: import google.generativeai as genai except ImportError: raise ImportError( - "'google-generativeai' package not installed. Run: pip install google-generativeai" + "'google-generativeai' package not installed. Run: pip install google-generativeai\n" + "Or set VIT_LLM_URL to use an OpenAI-compatible API instead." ) - api_key = _load_api_key() - if not api_key: + if not config.api_key: raise ValueError( - "GEMINI_API_KEY not set. Add it to .env or set the environment variable." + "GEMINI_API_KEY not set. Add it to .env or set the environment variable.\n" + "Or set VIT_LLM_URL to use an OpenAI-compatible API instead." ) - genai.configure(api_key=api_key) + genai.configure(api_key=config.api_key) return genai.GenerativeModel( "gemini-2.5-flash", @@ -319,6 +426,23 @@ def _get_genai_model(system_prompt: str): ) +def _get_genai_model(system_prompt: str): + """Get a configured model (Gemini or local LLM), handling import and setup. + + This is an alias for _get_llm_model for backward compatibility. + """ + return _get_llm_model(system_prompt) + + +def _extract_json_from_response(content: str) -> dict: + """Extract JSON from LLM response, handling markdown code blocks.""" + if "```json" in content: + content = content.split("```json")[1].split("```")[0] + elif "```" in content: + content = content.split("```")[1].split("```")[0] + return json.loads(content.strip()) + + def ai_analyze_merge( base_files: Dict[str, dict], ours_files: Dict[str, dict], @@ -339,7 +463,7 @@ def ai_analyze_merge( MergeAnalysis with per-domain decisions, or None if analysis fails """ try: - model = _get_genai_model(MERGE_ANALYSIS_SYSTEM_PROMPT) + model = _get_llm_model(MERGE_ANALYSIS_SYSTEM_PROMPT) except (ImportError, ValueError) as e: print(f"Error: {e}") return None @@ -357,7 +481,7 @@ def ai_analyze_merge( print(f"Error: AI returned invalid JSON: {e}") return None except Exception as e: - print(f"Error calling Gemini API: {e}") + print(f"Error calling LLM API: {e}") return None @@ -384,7 +508,7 @@ def ai_resolve_clarifications( return {} try: - model = _get_genai_model(MERGE_CLARIFICATION_SYSTEM_PROMPT) + model = _get_llm_model(MERGE_CLARIFICATION_SYSTEM_PROMPT) except (ImportError, ValueError) as e: print(f"Error: {e}") return None @@ -399,7 +523,7 @@ def ai_resolve_clarifications( print(f"Error: AI returned invalid JSON: {e}") return None except Exception as e: - print(f"Error calling Gemini API: {e}") + print(f"Error calling LLM API: {e}") return None @@ -410,7 +534,7 @@ def ai_merge( issues: List[ValidationIssue], conflicted_files: Optional[List[str]] = None, ) -> Optional[Dict[str, dict]]: - """Use Gemini API to resolve merge conflicts (legacy one-shot API). + """Use LLM API to resolve merge conflicts (legacy one-shot API). Args: base_files: Domain files from merge base @@ -422,6 +546,38 @@ def ai_merge( Returns: Dict of resolved domain files, or None if resolution fails """ + config = load_llm_config() + + if config.provider == "openai": + try: + from openai import OpenAI + except ImportError: + print("Error: 'openai' package not installed. Run: pip install openai") + return None + + if not config.base_url: + print("Error: VIT_LLM_URL not set.") + return None + + client = OpenAI(base_url=config.base_url, api_key=config.api_key or "not-needed") + + prompt = _build_merge_prompt( + base_files, ours_files, theirs_files, issues, conflicted_files or [] + ) + + try: + model = LocalLLMModel( + client=client, + model=config.model or "qwen2.5-coder:14b", + system_instruction=MERGE_SYSTEM_PROMPT + ) + response = model.generate_content(prompt) + return _extract_json_from_response(response.text) + except Exception as e: + print(f"Error calling OpenAI-compatible API: {e}") + return None + + # Gemini path try: import google.generativeai as genai except ImportError: @@ -685,7 +841,7 @@ def analyze_branch_comparison( Dict with summary, conflicts, recommendation, and explanation """ try: - model = _get_genai_model("You are a video editing merge advisor.") + model = _get_llm_model("You are a video editing merge advisor.") except (ImportError, ValueError) as e: return { "summary_a": f"Branch with {sum(len(v) for v in changes_a.values())} changes", @@ -778,7 +934,7 @@ def classify_commit_type( files_changed: List[str], message: str = "", ) -> str: - """Use Gemini to classify a commit's primary category. + """Use LLM to classify a commit's primary category. Args: commit_hash: Short commit hash @@ -805,7 +961,7 @@ def classify_commit_type( # Use AI for ambiguous cases try: - model = _get_genai_model("You are a video editing commit classifier.") + model = _get_llm_model("You are a video editing commit classifier.") except (ImportError, ValueError): # Fallback to simple heuristic from .core import categorize_commit @@ -852,7 +1008,7 @@ def classify_commit_type( def suggest_commit_message(diff_text: str) -> Optional[str]: - """Use Gemini to suggest a commit message based on timeline diff. + """Use LLM to suggest a commit message based on timeline diff. Args: diff_text: Human-readable diff output from differ.py @@ -864,7 +1020,7 @@ def suggest_commit_message(diff_text: str) -> Optional[str]: return None try: - model = _get_genai_model("You are a video editing commit message writer.") + model = _get_llm_model("You are a video editing commit message writer.") except (ImportError, ValueError): return None @@ -897,7 +1053,7 @@ def suggest_commit_message(diff_text: str) -> Optional[str]: def summarize_log(commits_text: str) -> Optional[str]: - """Use Gemini to summarize recent commit history. + """Use LLM to summarize recent commit history. Args: commits_text: Formatted git log output @@ -909,7 +1065,7 @@ def summarize_log(commits_text: str) -> Optional[str]: return None try: - model = _get_genai_model("You are a video editing project summarizer.") + model = _get_llm_model("You are a video editing project summarizer.") except (ImportError, ValueError): return None diff --git a/vit/cli.py b/vit/cli.py index 1139e30..e0b0ef8 100644 --- a/vit/cli.py +++ b/vit/cli.py @@ -5,6 +5,7 @@ import os import shutil import sys +from typing import Optional from . import __version__ from .core import ( @@ -467,6 +468,118 @@ def cmd_validate(args): print(" Validation passed — no issues found.") +def cmd_config(args): + """Configure AI/LLM settings for the project.""" + from .ai_merge import load_llm_config + + project_dir = _require_project() + env_path = os.path.join(project_dir, ".env") + + if args.list: + # Show current config + config = load_llm_config() + print(" AI Configuration:") + print(" " + "-" * 40) + if config.provider == "openai": + print(f" Provider: OpenAI-compatible API") + print(f" URL: {config.base_url or '(not set)'}") + print(f" Model: {config.model or '(default: qwen2.5-coder:14b)'}") + else: + print(f" Provider: Gemini") + gemini_key = _load_api_key_from_env(project_dir) + if gemini_key: + masked = gemini_key[:8] + "..." + gemini_key[-4:] if len(gemini_key) > 12 else "***" + print(f" GEMINI_API_KEY: {masked}") + else: + print(f" GEMINI_API_KEY: (not set)") + + # Show .env file location + if os.path.exists(env_path): + print(f" Config file: {env_path}") + else: + print(f" Config file: {env_path} (will be created when setting values)") + return + + # Read existing .env content + env_vars = {} + if os.path.exists(env_path): + with open(env_path) as f: + for line in f: + line = line.strip() + if line and "=" in line and not line.startswith("#"): + key, value = line.split("=", 1) + env_vars[key] = value.strip().strip('"').strip("'") + + updated = False + + if args.gemini_key: + env_vars["GEMINI_API_KEY"] = args.gemini_key + print(" Set GEMINI_API_KEY") + updated = True + # If setting Gemini key, remove local LLM URL to avoid conflict + if "VIT_LLM_URL" in env_vars: + del env_vars["VIT_LLM_URL"] + print(" Cleared VIT_LLM_URL (using Gemini)") + + if args.llm_url: + env_vars["VIT_LLM_URL"] = args.llm_url + print(f" Set VIT_LLM_URL to {args.llm_url}") + print(f" (Using OpenAI-compatible API)") + updated = True + + if args.llm_model: + env_vars["VIT_LLM_MODEL"] = args.llm_model + print(f" Set VIT_LLM_MODEL to {args.llm_model}") + updated = True + + if args.clear_gemini: + if "GEMINI_API_KEY" in env_vars: + del env_vars["GEMINI_API_KEY"] + print(" Cleared GEMINI_API_KEY") + updated = True + + if args.clear_llm: + if "VIT_LLM_URL" in env_vars: + del env_vars["VIT_LLM_URL"] + print(" Cleared VIT_LLM_URL") + updated = True + if "VIT_LLM_MODEL" in env_vars: + del env_vars["VIT_LLM_MODEL"] + print(" Cleared VIT_LLM_MODEL") + updated = True + + if updated: + # Write back to .env + with open(env_path, "w") as f: + f.write("# Vit AI Configuration\n") + f.write("# Set either GEMINI_API_KEY or VIT_LLM_URL (local LLM takes priority)\n\n") + for key, value in sorted(env_vars.items()): + # Quote value if it contains spaces + if " " in value: + f.write(f'{key}="{value}"\n') + else: + f.write(f"{key}={value}\n") + print(f"\n Configuration saved to {env_path}") + else: + print(" No changes made. Use --help to see available options.") + + +def _load_api_key_from_env(project_dir: str) -> Optional[str]: + """Load GEMINI_API_KEY from environment or .env file.""" + key = os.environ.get("GEMINI_API_KEY") + if key: + return key + + env_path = os.path.join(project_dir, ".env") + if os.path.exists(env_path): + with open(env_path) as f: + for line in f: + line = line.strip() + if line.startswith("GEMINI_API_KEY="): + return line.split("=", 1)[1].strip().strip('"') + return None + + if sys.platform == "win32": RESOLVE_SCRIPTS_DIR = os.path.join( os.environ.get("APPDATA", ""), @@ -747,6 +860,16 @@ def main(): p_validate = subparsers.add_parser("validate", help="Validate timeline consistency") p_validate.set_defaults(func=cmd_validate) + # config + p_config = subparsers.add_parser("config", help="Configure AI/LLM settings") + p_config.add_argument("-l", "--list", action="store_true", help="Show current configuration") + p_config.add_argument("--gemini-key", help="Set Gemini API key") + p_config.add_argument("--llm-url", help="Set OpenAI-compatible API URL (e.g., http://localhost:11434/v1, https://api.openai.com/v1)") + p_config.add_argument("--llm-model", help="Set model name for OpenAI-compatible API (default: qwen2.5-coder:14b)") + p_config.add_argument("--clear-gemini", action="store_true", help="Remove Gemini API key") + p_config.add_argument("--clear-llm", action="store_true", help="Remove OpenAI-compatible API settings") + p_config.set_defaults(func=cmd_config) + # clone p_clone = subparsers.add_parser("clone", help="Clone a remote vit project") p_clone.add_argument("url", help="Remote URL to clone")