gitflow-analytics 1.0.1__py3-none-any.whl → 1.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitflow_analytics/__init__.py +11 -11
- gitflow_analytics/_version.py +2 -2
- gitflow_analytics/cli.py +612 -258
- gitflow_analytics/cli_rich.py +353 -0
- gitflow_analytics/config.py +251 -141
- gitflow_analytics/core/analyzer.py +140 -103
- gitflow_analytics/core/branch_mapper.py +132 -132
- gitflow_analytics/core/cache.py +240 -169
- gitflow_analytics/core/identity.py +210 -173
- gitflow_analytics/extractors/base.py +13 -11
- gitflow_analytics/extractors/story_points.py +70 -59
- gitflow_analytics/extractors/tickets.py +101 -87
- gitflow_analytics/integrations/github_integration.py +84 -77
- gitflow_analytics/integrations/jira_integration.py +116 -104
- gitflow_analytics/integrations/orchestrator.py +86 -85
- gitflow_analytics/metrics/dora.py +181 -177
- gitflow_analytics/models/database.py +190 -53
- gitflow_analytics/qualitative/__init__.py +30 -0
- gitflow_analytics/qualitative/classifiers/__init__.py +13 -0
- gitflow_analytics/qualitative/classifiers/change_type.py +468 -0
- gitflow_analytics/qualitative/classifiers/domain_classifier.py +399 -0
- gitflow_analytics/qualitative/classifiers/intent_analyzer.py +436 -0
- gitflow_analytics/qualitative/classifiers/risk_analyzer.py +412 -0
- gitflow_analytics/qualitative/core/__init__.py +13 -0
- gitflow_analytics/qualitative/core/llm_fallback.py +653 -0
- gitflow_analytics/qualitative/core/nlp_engine.py +373 -0
- gitflow_analytics/qualitative/core/pattern_cache.py +457 -0
- gitflow_analytics/qualitative/core/processor.py +540 -0
- gitflow_analytics/qualitative/models/__init__.py +25 -0
- gitflow_analytics/qualitative/models/schemas.py +272 -0
- gitflow_analytics/qualitative/utils/__init__.py +13 -0
- gitflow_analytics/qualitative/utils/batch_processor.py +326 -0
- gitflow_analytics/qualitative/utils/cost_tracker.py +343 -0
- gitflow_analytics/qualitative/utils/metrics.py +347 -0
- gitflow_analytics/qualitative/utils/text_processing.py +243 -0
- gitflow_analytics/reports/analytics_writer.py +11 -4
- gitflow_analytics/reports/csv_writer.py +51 -31
- gitflow_analytics/reports/narrative_writer.py +16 -14
- gitflow_analytics/tui/__init__.py +5 -0
- gitflow_analytics/tui/app.py +721 -0
- gitflow_analytics/tui/screens/__init__.py +8 -0
- gitflow_analytics/tui/screens/analysis_progress_screen.py +487 -0
- gitflow_analytics/tui/screens/configuration_screen.py +547 -0
- gitflow_analytics/tui/screens/loading_screen.py +358 -0
- gitflow_analytics/tui/screens/main_screen.py +304 -0
- gitflow_analytics/tui/screens/results_screen.py +698 -0
- gitflow_analytics/tui/widgets/__init__.py +7 -0
- gitflow_analytics/tui/widgets/data_table.py +257 -0
- gitflow_analytics/tui/widgets/export_modal.py +301 -0
- gitflow_analytics/tui/widgets/progress_widget.py +192 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/METADATA +31 -4
- gitflow_analytics-1.0.3.dist-info/RECORD +62 -0
- gitflow_analytics-1.0.1.dist-info/RECORD +0 -31
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/WHEEL +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""LLM fallback system for uncertain commit classifications using OpenRouter."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Dict, List, Optional, Tuple, Any
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from ..models.schemas import LLMConfig, QualitativeCommitData
|
|
13
|
+
from ..utils.cost_tracker import CostTracker
|
|
14
|
+
from ..utils.text_processing import TextProcessor
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import openai
|
|
18
|
+
import tiktoken
|
|
19
|
+
OPENAI_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
OPENAI_AVAILABLE = False
|
|
22
|
+
# Create mock objects for type hints when not available
|
|
23
|
+
class MockOpenAI:
|
|
24
|
+
class OpenAI:
|
|
25
|
+
pass
|
|
26
|
+
openai = MockOpenAI()
|
|
27
|
+
tiktoken = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ModelRouter:
|
|
31
|
+
"""Smart model selection based on complexity and cost constraints."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: LLMConfig, cost_tracker: CostTracker):
|
|
34
|
+
"""Initialize model router.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
config: LLM configuration
|
|
38
|
+
cost_tracker: Cost tracking instance
|
|
39
|
+
"""
|
|
40
|
+
self.config = config
|
|
41
|
+
self.cost_tracker = cost_tracker
|
|
42
|
+
self.logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
def select_model(self, complexity_score: float, batch_size: int) -> str:
|
|
45
|
+
"""Select appropriate model based on complexity and budget.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
complexity_score: Complexity score (0.0 to 1.0)
|
|
49
|
+
batch_size: Number of commits in batch
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Selected model name
|
|
53
|
+
"""
|
|
54
|
+
# Check daily budget remaining
|
|
55
|
+
remaining_budget = self.cost_tracker.check_budget_remaining()
|
|
56
|
+
|
|
57
|
+
# If we're over budget, use free model
|
|
58
|
+
if remaining_budget <= 0:
|
|
59
|
+
self.logger.warning("Daily budget exceeded, using free model")
|
|
60
|
+
return self.config.fallback_model
|
|
61
|
+
|
|
62
|
+
# For simple cases or when budget is tight, use free model
|
|
63
|
+
if complexity_score < 0.3 or remaining_budget < 0.50:
|
|
64
|
+
return self.config.fallback_model
|
|
65
|
+
|
|
66
|
+
# For complex cases with sufficient budget, use premium model
|
|
67
|
+
if complexity_score > self.config.complexity_threshold and remaining_budget > 2.0:
|
|
68
|
+
return self.config.complex_model
|
|
69
|
+
|
|
70
|
+
# Default to primary model (Claude Haiku - fast and cheap)
|
|
71
|
+
return self.config.primary_model
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class LLMFallback:
|
|
75
|
+
"""Strategic LLM usage for uncertain cases via OpenRouter.
|
|
76
|
+
|
|
77
|
+
This class provides intelligent fallback to LLM processing when NLP
|
|
78
|
+
classification confidence is below the threshold. It uses OpenRouter
|
|
79
|
+
to access multiple models cost-effectively.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, config: LLMConfig):
|
|
83
|
+
"""Initialize LLM fallback system.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
config: LLM configuration
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ImportError: If OpenAI library is not available
|
|
90
|
+
"""
|
|
91
|
+
if not OPENAI_AVAILABLE:
|
|
92
|
+
raise ImportError(
|
|
93
|
+
"OpenAI library required for LLM fallback. Install with: pip install openai"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self.config = config
|
|
97
|
+
self.logger = logging.getLogger(__name__)
|
|
98
|
+
|
|
99
|
+
# Initialize OpenRouter client
|
|
100
|
+
self.client = self._initialize_openrouter_client()
|
|
101
|
+
|
|
102
|
+
# Initialize utilities
|
|
103
|
+
self.cost_tracker = CostTracker(daily_budget=config.max_daily_cost)
|
|
104
|
+
self.model_router = ModelRouter(config, self.cost_tracker)
|
|
105
|
+
self.text_processor = TextProcessor()
|
|
106
|
+
|
|
107
|
+
# Batch processing cache
|
|
108
|
+
self.batch_cache = {}
|
|
109
|
+
|
|
110
|
+
# Token encoder for cost estimation
|
|
111
|
+
try:
|
|
112
|
+
self.encoding = tiktoken.get_encoding("cl100k_base") # GPT-4 encoding
|
|
113
|
+
except Exception:
|
|
114
|
+
self.encoding = None
|
|
115
|
+
self.logger.warning("Could not load tiktoken encoder, token estimation may be inaccurate")
|
|
116
|
+
|
|
117
|
+
self.logger.info("LLM fallback system initialized with OpenRouter")
|
|
118
|
+
|
|
119
|
+
def _initialize_openrouter_client(self) -> openai.OpenAI:
|
|
120
|
+
"""Initialize OpenRouter client with API key.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Configured OpenAI client for OpenRouter
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: If API key is not configured
|
|
127
|
+
"""
|
|
128
|
+
api_key = self._resolve_api_key()
|
|
129
|
+
if not api_key:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"OpenRouter API key not configured. Set OPENROUTER_API_KEY environment variable."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return openai.OpenAI(
|
|
135
|
+
base_url=self.config.base_url,
|
|
136
|
+
api_key=api_key,
|
|
137
|
+
default_headers={
|
|
138
|
+
"HTTP-Referer": "https://github.com/bobmatnyc/gitflow-analytics",
|
|
139
|
+
"X-Title": "GitFlow Analytics - Qualitative Analysis"
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _resolve_api_key(self) -> Optional[str]:
|
|
144
|
+
"""Resolve OpenRouter API key from config or environment.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
API key string or None if not found
|
|
148
|
+
"""
|
|
149
|
+
api_key = self.config.openrouter_api_key
|
|
150
|
+
|
|
151
|
+
if api_key.startswith("${") and api_key.endswith("}"):
|
|
152
|
+
env_var = api_key[2:-1]
|
|
153
|
+
return os.environ.get(env_var)
|
|
154
|
+
else:
|
|
155
|
+
return api_key
|
|
156
|
+
|
|
157
|
+
def group_similar_commits(self, commits: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
|
|
158
|
+
"""Group similar commits for efficient batch processing.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
commits: List of commit dictionaries
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of commit groups
|
|
165
|
+
"""
|
|
166
|
+
if not commits:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
groups = []
|
|
170
|
+
similarity_threshold = self.config.similarity_threshold
|
|
171
|
+
|
|
172
|
+
for commit in commits:
|
|
173
|
+
# Find similar group or create new one
|
|
174
|
+
placed = False
|
|
175
|
+
|
|
176
|
+
for group in groups:
|
|
177
|
+
if len(group) >= self.config.max_group_size:
|
|
178
|
+
continue # Group is full
|
|
179
|
+
|
|
180
|
+
# Calculate similarity with first commit in group
|
|
181
|
+
similarity = self.text_processor.calculate_message_similarity(
|
|
182
|
+
commit.get('message', ''),
|
|
183
|
+
group[0].get('message', '')
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if similarity > similarity_threshold:
|
|
187
|
+
group.append(commit)
|
|
188
|
+
placed = True
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
if not placed:
|
|
192
|
+
groups.append([commit])
|
|
193
|
+
|
|
194
|
+
self.logger.debug(f"Grouped {len(commits)} commits into {len(groups)} groups")
|
|
195
|
+
return groups
|
|
196
|
+
|
|
197
|
+
def process_group(self, commits: List[Dict[str, Any]]) -> List[QualitativeCommitData]:
|
|
198
|
+
"""Process a group of similar commits with OpenRouter.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
commits: List of similar commit dictionaries
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of QualitativeCommitData with LLM analysis
|
|
205
|
+
"""
|
|
206
|
+
if not commits:
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
start_time = time.time()
|
|
210
|
+
|
|
211
|
+
# Check cache first
|
|
212
|
+
cache_key = self._generate_group_cache_key(commits)
|
|
213
|
+
if cache_key in self.batch_cache:
|
|
214
|
+
self.logger.debug(f"Using cached result for {len(commits)} commits")
|
|
215
|
+
template_result = self.batch_cache[cache_key]
|
|
216
|
+
return self._apply_template_to_group(template_result, commits)
|
|
217
|
+
|
|
218
|
+
# Assess complexity and select model
|
|
219
|
+
complexity_score = self._assess_complexity(commits)
|
|
220
|
+
selected_model = self.model_router.select_model(complexity_score, len(commits))
|
|
221
|
+
|
|
222
|
+
self.logger.debug(
|
|
223
|
+
f"Processing {len(commits)} commits with {selected_model} "
|
|
224
|
+
f"(complexity: {complexity_score:.2f})"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Build optimized prompt
|
|
228
|
+
prompt = self._build_batch_classification_prompt(commits)
|
|
229
|
+
|
|
230
|
+
# Estimate tokens and cost
|
|
231
|
+
estimated_input_tokens = self._estimate_tokens(prompt)
|
|
232
|
+
if not self.cost_tracker.can_afford_call(selected_model, estimated_input_tokens * 2):
|
|
233
|
+
self.logger.warning("Cannot afford LLM call, using fallback model")
|
|
234
|
+
selected_model = self.config.fallback_model
|
|
235
|
+
|
|
236
|
+
# Make OpenRouter API call
|
|
237
|
+
try:
|
|
238
|
+
response = self._call_openrouter(prompt, selected_model)
|
|
239
|
+
processing_time = time.time() - start_time
|
|
240
|
+
|
|
241
|
+
# Parse response
|
|
242
|
+
results = self._parse_llm_response(response, commits)
|
|
243
|
+
|
|
244
|
+
# Track costs and performance
|
|
245
|
+
estimated_output_tokens = self._estimate_tokens(response)
|
|
246
|
+
self.cost_tracker.record_call(
|
|
247
|
+
model=selected_model,
|
|
248
|
+
input_tokens=estimated_input_tokens,
|
|
249
|
+
output_tokens=estimated_output_tokens,
|
|
250
|
+
processing_time=processing_time,
|
|
251
|
+
batch_size=len(commits),
|
|
252
|
+
success=len(results) > 0
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Cache successful result
|
|
256
|
+
if results:
|
|
257
|
+
self.batch_cache[cache_key] = self._create_template_from_results(results)
|
|
258
|
+
|
|
259
|
+
# Update processing time in results
|
|
260
|
+
for result in results:
|
|
261
|
+
result.processing_time_ms = (processing_time * 1000) / len(results)
|
|
262
|
+
|
|
263
|
+
return results
|
|
264
|
+
|
|
265
|
+
except Exception as e:
|
|
266
|
+
self.logger.error(f"OpenRouter processing failed: {e}")
|
|
267
|
+
|
|
268
|
+
# Record failed call
|
|
269
|
+
self.cost_tracker.record_call(
|
|
270
|
+
model=selected_model,
|
|
271
|
+
input_tokens=estimated_input_tokens,
|
|
272
|
+
output_tokens=0,
|
|
273
|
+
processing_time=time.time() - start_time,
|
|
274
|
+
batch_size=len(commits),
|
|
275
|
+
success=False,
|
|
276
|
+
error_message=str(e)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Try fallback model if primary failed
|
|
280
|
+
if selected_model != self.config.fallback_model:
|
|
281
|
+
return self._retry_with_fallback_model(commits, prompt)
|
|
282
|
+
else:
|
|
283
|
+
return self._create_fallback_results(commits)
|
|
284
|
+
|
|
285
|
+
def _call_openrouter(self, prompt: str, model: str) -> str:
|
|
286
|
+
"""Make API call to OpenRouter with selected model.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
prompt: Classification prompt
|
|
290
|
+
model: Model to use
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Response content
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
Exception: If API call fails
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
response = self.client.chat.completions.create(
|
|
300
|
+
model=model,
|
|
301
|
+
messages=[
|
|
302
|
+
{
|
|
303
|
+
"role": "system",
|
|
304
|
+
"content": "You are an expert Git commit classifier. Analyze commits and respond only with valid JSON. Be concise but accurate."
|
|
305
|
+
},
|
|
306
|
+
{"role": "user", "content": prompt}
|
|
307
|
+
],
|
|
308
|
+
max_tokens=self.config.max_tokens,
|
|
309
|
+
temperature=self.config.temperature,
|
|
310
|
+
stream=False
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return response.choices[0].message.content
|
|
314
|
+
|
|
315
|
+
except Exception as e:
|
|
316
|
+
self.logger.error(f"OpenRouter API call failed: {e}")
|
|
317
|
+
raise
|
|
318
|
+
|
|
319
|
+
def _build_batch_classification_prompt(self, commits: List[Dict[str, Any]]) -> str:
|
|
320
|
+
"""Build optimized prompt for OpenRouter batch processing.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
commits: List of commit dictionaries
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Formatted prompt string
|
|
327
|
+
"""
|
|
328
|
+
# Limit to max group size for token management
|
|
329
|
+
commits_to_process = commits[:self.config.max_group_size]
|
|
330
|
+
|
|
331
|
+
commit_data = []
|
|
332
|
+
for i, commit in enumerate(commits_to_process, 1):
|
|
333
|
+
message = commit.get('message', '')[:150] # Truncate long messages
|
|
334
|
+
files = commit.get('files_changed', [])
|
|
335
|
+
|
|
336
|
+
# Include key file context
|
|
337
|
+
files_context = ""
|
|
338
|
+
if files:
|
|
339
|
+
key_files = files[:5] # Top 5 files
|
|
340
|
+
files_context = f" | Modified: {', '.join(key_files)}"
|
|
341
|
+
|
|
342
|
+
# Add size context
|
|
343
|
+
insertions = commit.get('insertions', 0)
|
|
344
|
+
deletions = commit.get('deletions', 0)
|
|
345
|
+
size_context = f" | +{insertions}/-{deletions}"
|
|
346
|
+
|
|
347
|
+
commit_data.append(f"{i}. {message}{files_context}{size_context}")
|
|
348
|
+
|
|
349
|
+
prompt = f"""Analyze these Git commits and classify each one. Consider the commit message, modified files, and change size.
|
|
350
|
+
|
|
351
|
+
Commits to classify:
|
|
352
|
+
{chr(10).join(commit_data)}
|
|
353
|
+
|
|
354
|
+
For each commit, provide:
|
|
355
|
+
- change_type: feature|bugfix|refactor|docs|test|chore|security|hotfix|config
|
|
356
|
+
- business_domain: frontend|backend|database|infrastructure|mobile|devops|unknown
|
|
357
|
+
- risk_level: low|medium|high|critical
|
|
358
|
+
- confidence: 0.0-1.0 (classification certainty)
|
|
359
|
+
- urgency: routine|important|urgent|critical
|
|
360
|
+
- complexity: simple|moderate|complex
|
|
361
|
+
|
|
362
|
+
Respond with JSON array only:
|
|
363
|
+
[{{"id": 1, "change_type": "feature", "business_domain": "frontend", "risk_level": "low", "confidence": 0.9, "urgency": "routine", "complexity": "moderate"}}]"""
|
|
364
|
+
|
|
365
|
+
return prompt
|
|
366
|
+
|
|
367
|
+
def _parse_llm_response(self, response: str, commits: List[Dict[str, Any]]) -> List[QualitativeCommitData]:
|
|
368
|
+
"""Parse LLM response into QualitativeCommitData objects.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
response: JSON response from LLM
|
|
372
|
+
commits: Original commit dictionaries
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
List of QualitativeCommitData objects
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
# Clean response (remove any markdown formatting)
|
|
379
|
+
cleaned_response = response.strip()
|
|
380
|
+
if cleaned_response.startswith('```json'):
|
|
381
|
+
cleaned_response = cleaned_response[7:]
|
|
382
|
+
if cleaned_response.endswith('```'):
|
|
383
|
+
cleaned_response = cleaned_response[:-3]
|
|
384
|
+
cleaned_response = cleaned_response.strip()
|
|
385
|
+
|
|
386
|
+
classifications = json.loads(cleaned_response)
|
|
387
|
+
|
|
388
|
+
if not isinstance(classifications, list):
|
|
389
|
+
raise ValueError("Response is not a JSON array")
|
|
390
|
+
|
|
391
|
+
results = []
|
|
392
|
+
|
|
393
|
+
for i, commit in enumerate(commits):
|
|
394
|
+
if i < len(classifications):
|
|
395
|
+
classification = classifications[i]
|
|
396
|
+
else:
|
|
397
|
+
# Fallback if fewer classifications than commits
|
|
398
|
+
classification = {
|
|
399
|
+
'change_type': 'unknown',
|
|
400
|
+
'business_domain': 'unknown',
|
|
401
|
+
'risk_level': 'medium',
|
|
402
|
+
'confidence': 0.5,
|
|
403
|
+
'urgency': 'routine',
|
|
404
|
+
'complexity': 'moderate'
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
result = QualitativeCommitData(
|
|
408
|
+
# Copy existing commit fields
|
|
409
|
+
hash=commit.get('hash', ''),
|
|
410
|
+
message=commit.get('message', ''),
|
|
411
|
+
author_name=commit.get('author_name', ''),
|
|
412
|
+
author_email=commit.get('author_email', ''),
|
|
413
|
+
timestamp=commit.get('timestamp', time.time()),
|
|
414
|
+
files_changed=commit.get('files_changed', []),
|
|
415
|
+
insertions=commit.get('insertions', 0),
|
|
416
|
+
deletions=commit.get('deletions', 0),
|
|
417
|
+
|
|
418
|
+
# LLM-provided classifications
|
|
419
|
+
change_type=classification.get('change_type', 'unknown'),
|
|
420
|
+
change_type_confidence=classification.get('confidence', 0.5),
|
|
421
|
+
business_domain=classification.get('business_domain', 'unknown'),
|
|
422
|
+
domain_confidence=classification.get('confidence', 0.5),
|
|
423
|
+
risk_level=classification.get('risk_level', 'medium'),
|
|
424
|
+
risk_factors=classification.get('risk_factors', []),
|
|
425
|
+
|
|
426
|
+
# Intent signals from LLM analysis
|
|
427
|
+
intent_signals={
|
|
428
|
+
'urgency': classification.get('urgency', 'routine'),
|
|
429
|
+
'complexity': classification.get('complexity', 'moderate'),
|
|
430
|
+
'confidence': classification.get('confidence', 0.5),
|
|
431
|
+
'signals': [f"llm_classified:{classification.get('change_type', 'unknown')}"]
|
|
432
|
+
},
|
|
433
|
+
collaboration_patterns={},
|
|
434
|
+
technical_context={
|
|
435
|
+
'llm_model': 'openrouter',
|
|
436
|
+
'processing_method': 'batch'
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
# Processing metadata
|
|
440
|
+
processing_method='llm',
|
|
441
|
+
processing_time_ms=0, # Set by caller
|
|
442
|
+
confidence_score=classification.get('confidence', 0.5)
|
|
443
|
+
)
|
|
444
|
+
results.append(result)
|
|
445
|
+
|
|
446
|
+
return results
|
|
447
|
+
|
|
448
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
449
|
+
self.logger.error(f"Failed to parse LLM response: {e}")
|
|
450
|
+
self.logger.debug(f"Raw response: {response}")
|
|
451
|
+
return self._create_fallback_results(commits)
|
|
452
|
+
|
|
453
|
+
def _assess_complexity(self, commits: List[Dict[str, Any]]) -> float:
|
|
454
|
+
"""Assess complexity of commits for model selection.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
commits: List of commit dictionaries
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Complexity score (0.0 to 1.0)
|
|
461
|
+
"""
|
|
462
|
+
if not commits:
|
|
463
|
+
return 0.0
|
|
464
|
+
|
|
465
|
+
total_complexity = 0.0
|
|
466
|
+
|
|
467
|
+
for commit in commits:
|
|
468
|
+
# Message complexity
|
|
469
|
+
message = commit.get('message', '')
|
|
470
|
+
message_complexity = min(1.0, len(message.split()) / 20.0)
|
|
471
|
+
|
|
472
|
+
# File change complexity
|
|
473
|
+
files_changed = len(commit.get('files_changed', []))
|
|
474
|
+
file_complexity = min(1.0, files_changed / 15.0)
|
|
475
|
+
|
|
476
|
+
# Size complexity
|
|
477
|
+
total_changes = commit.get('insertions', 0) + commit.get('deletions', 0)
|
|
478
|
+
size_complexity = min(1.0, total_changes / 200.0)
|
|
479
|
+
|
|
480
|
+
# Combine complexities
|
|
481
|
+
commit_complexity = (message_complexity * 0.3 +
|
|
482
|
+
file_complexity * 0.4 +
|
|
483
|
+
size_complexity * 0.3)
|
|
484
|
+
total_complexity += commit_complexity
|
|
485
|
+
|
|
486
|
+
return total_complexity / len(commits)
|
|
487
|
+
|
|
488
|
+
def _estimate_tokens(self, text: str) -> int:
|
|
489
|
+
"""Estimate token count for text.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
text: Text to count tokens for
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
Estimated token count
|
|
496
|
+
"""
|
|
497
|
+
if self.encoding:
|
|
498
|
+
try:
|
|
499
|
+
return len(self.encoding.encode(text))
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|
|
502
|
+
|
|
503
|
+
# Fallback estimation (roughly 4 characters per token)
|
|
504
|
+
return len(text) // 4
|
|
505
|
+
|
|
506
|
+
def _generate_group_cache_key(self, commits: List[Dict[str, Any]]) -> str:
|
|
507
|
+
"""Generate cache key for a group of commits.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
commits: List of commit dictionaries
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
Cache key string
|
|
514
|
+
"""
|
|
515
|
+
# Create fingerprint from commit messages and file patterns
|
|
516
|
+
fingerprints = []
|
|
517
|
+
for commit in commits:
|
|
518
|
+
message = commit.get('message', '')
|
|
519
|
+
files = commit.get('files_changed', [])
|
|
520
|
+
fingerprint = self.text_processor.create_semantic_fingerprint(message, files)
|
|
521
|
+
fingerprints.append(fingerprint)
|
|
522
|
+
|
|
523
|
+
combined_fingerprint = '|'.join(sorted(fingerprints))
|
|
524
|
+
return hashlib.md5(combined_fingerprint.encode()).hexdigest()
|
|
525
|
+
|
|
526
|
+
def _create_template_from_results(self, results: List[QualitativeCommitData]) -> Dict[str, Any]:
|
|
527
|
+
"""Create a template from successful results for caching.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
results: List of analysis results
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Template dictionary
|
|
534
|
+
"""
|
|
535
|
+
if not results:
|
|
536
|
+
return {}
|
|
537
|
+
|
|
538
|
+
# Use first result as template
|
|
539
|
+
template = results[0]
|
|
540
|
+
return {
|
|
541
|
+
'change_type': template.change_type,
|
|
542
|
+
'business_domain': template.business_domain,
|
|
543
|
+
'risk_level': template.risk_level,
|
|
544
|
+
'confidence_score': template.confidence_score
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
def _apply_template_to_group(self, template: Dict[str, Any],
|
|
548
|
+
commits: List[Dict[str, Any]]) -> List[QualitativeCommitData]:
|
|
549
|
+
"""Apply cached template to a group of commits.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
template: Cached analysis template
|
|
553
|
+
commits: List of commit dictionaries
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
List of QualitativeCommitData using template
|
|
557
|
+
"""
|
|
558
|
+
results = []
|
|
559
|
+
|
|
560
|
+
for commit in commits:
|
|
561
|
+
result = QualitativeCommitData(
|
|
562
|
+
# Copy existing commit fields
|
|
563
|
+
hash=commit.get('hash', ''),
|
|
564
|
+
message=commit.get('message', ''),
|
|
565
|
+
author_name=commit.get('author_name', ''),
|
|
566
|
+
author_email=commit.get('author_email', ''),
|
|
567
|
+
timestamp=commit.get('timestamp', time.time()),
|
|
568
|
+
files_changed=commit.get('files_changed', []),
|
|
569
|
+
insertions=commit.get('insertions', 0),
|
|
570
|
+
deletions=commit.get('deletions', 0),
|
|
571
|
+
|
|
572
|
+
# Apply template values
|
|
573
|
+
change_type=template.get('change_type', 'unknown'),
|
|
574
|
+
change_type_confidence=template.get('confidence_score', 0.5),
|
|
575
|
+
business_domain=template.get('business_domain', 'unknown'),
|
|
576
|
+
domain_confidence=template.get('confidence_score', 0.5),
|
|
577
|
+
risk_level=template.get('risk_level', 'medium'),
|
|
578
|
+
risk_factors=[],
|
|
579
|
+
|
|
580
|
+
intent_signals={'confidence': template.get('confidence_score', 0.5)},
|
|
581
|
+
collaboration_patterns={},
|
|
582
|
+
technical_context={'processing_method': 'cached_template'},
|
|
583
|
+
|
|
584
|
+
# Processing metadata
|
|
585
|
+
processing_method='llm',
|
|
586
|
+
processing_time_ms=1.0, # Very fast for cached results
|
|
587
|
+
confidence_score=template.get('confidence_score', 0.5)
|
|
588
|
+
)
|
|
589
|
+
results.append(result)
|
|
590
|
+
|
|
591
|
+
return results
|
|
592
|
+
|
|
593
|
+
def _retry_with_fallback_model(self, commits: List[Dict[str, Any]],
|
|
594
|
+
prompt: str) -> List[QualitativeCommitData]:
|
|
595
|
+
"""Retry processing with fallback model.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
commits: List of commit dictionaries
|
|
599
|
+
prompt: Classification prompt
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
List of QualitativeCommitData or fallback results
|
|
603
|
+
"""
|
|
604
|
+
try:
|
|
605
|
+
self.logger.info(f"Retrying with fallback model: {self.config.fallback_model}")
|
|
606
|
+
response = self._call_openrouter(prompt, self.config.fallback_model)
|
|
607
|
+
return self._parse_llm_response(response, commits)
|
|
608
|
+
except Exception as e:
|
|
609
|
+
self.logger.error(f"Fallback model also failed: {e}")
|
|
610
|
+
return self._create_fallback_results(commits)
|
|
611
|
+
|
|
612
|
+
def _create_fallback_results(self, commits: List[Dict[str, Any]]) -> List[QualitativeCommitData]:
|
|
613
|
+
"""Create fallback results when LLM processing fails.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
commits: List of commit dictionaries
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
List of QualitativeCommitData with default values
|
|
620
|
+
"""
|
|
621
|
+
results = []
|
|
622
|
+
|
|
623
|
+
for commit in commits:
|
|
624
|
+
result = QualitativeCommitData(
|
|
625
|
+
# Basic commit info
|
|
626
|
+
hash=commit.get('hash', ''),
|
|
627
|
+
message=commit.get('message', ''),
|
|
628
|
+
author_name=commit.get('author_name', ''),
|
|
629
|
+
author_email=commit.get('author_email', ''),
|
|
630
|
+
timestamp=commit.get('timestamp', time.time()),
|
|
631
|
+
files_changed=commit.get('files_changed', []),
|
|
632
|
+
insertions=commit.get('insertions', 0),
|
|
633
|
+
deletions=commit.get('deletions', 0),
|
|
634
|
+
|
|
635
|
+
# Default classifications
|
|
636
|
+
change_type='unknown',
|
|
637
|
+
change_type_confidence=0.0,
|
|
638
|
+
business_domain='unknown',
|
|
639
|
+
domain_confidence=0.0,
|
|
640
|
+
risk_level='medium',
|
|
641
|
+
risk_factors=['llm_processing_failed'],
|
|
642
|
+
intent_signals={'confidence': 0.0},
|
|
643
|
+
collaboration_patterns={},
|
|
644
|
+
technical_context={'processing_method': 'fallback'},
|
|
645
|
+
|
|
646
|
+
# Processing metadata
|
|
647
|
+
processing_method='llm',
|
|
648
|
+
processing_time_ms=0.0,
|
|
649
|
+
confidence_score=0.0
|
|
650
|
+
)
|
|
651
|
+
results.append(result)
|
|
652
|
+
|
|
653
|
+
return results
|