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.
Files changed (57) hide show
  1. gitflow_analytics/__init__.py +11 -11
  2. gitflow_analytics/_version.py +2 -2
  3. gitflow_analytics/cli.py +612 -258
  4. gitflow_analytics/cli_rich.py +353 -0
  5. gitflow_analytics/config.py +251 -141
  6. gitflow_analytics/core/analyzer.py +140 -103
  7. gitflow_analytics/core/branch_mapper.py +132 -132
  8. gitflow_analytics/core/cache.py +240 -169
  9. gitflow_analytics/core/identity.py +210 -173
  10. gitflow_analytics/extractors/base.py +13 -11
  11. gitflow_analytics/extractors/story_points.py +70 -59
  12. gitflow_analytics/extractors/tickets.py +101 -87
  13. gitflow_analytics/integrations/github_integration.py +84 -77
  14. gitflow_analytics/integrations/jira_integration.py +116 -104
  15. gitflow_analytics/integrations/orchestrator.py +86 -85
  16. gitflow_analytics/metrics/dora.py +181 -177
  17. gitflow_analytics/models/database.py +190 -53
  18. gitflow_analytics/qualitative/__init__.py +30 -0
  19. gitflow_analytics/qualitative/classifiers/__init__.py +13 -0
  20. gitflow_analytics/qualitative/classifiers/change_type.py +468 -0
  21. gitflow_analytics/qualitative/classifiers/domain_classifier.py +399 -0
  22. gitflow_analytics/qualitative/classifiers/intent_analyzer.py +436 -0
  23. gitflow_analytics/qualitative/classifiers/risk_analyzer.py +412 -0
  24. gitflow_analytics/qualitative/core/__init__.py +13 -0
  25. gitflow_analytics/qualitative/core/llm_fallback.py +653 -0
  26. gitflow_analytics/qualitative/core/nlp_engine.py +373 -0
  27. gitflow_analytics/qualitative/core/pattern_cache.py +457 -0
  28. gitflow_analytics/qualitative/core/processor.py +540 -0
  29. gitflow_analytics/qualitative/models/__init__.py +25 -0
  30. gitflow_analytics/qualitative/models/schemas.py +272 -0
  31. gitflow_analytics/qualitative/utils/__init__.py +13 -0
  32. gitflow_analytics/qualitative/utils/batch_processor.py +326 -0
  33. gitflow_analytics/qualitative/utils/cost_tracker.py +343 -0
  34. gitflow_analytics/qualitative/utils/metrics.py +347 -0
  35. gitflow_analytics/qualitative/utils/text_processing.py +243 -0
  36. gitflow_analytics/reports/analytics_writer.py +11 -4
  37. gitflow_analytics/reports/csv_writer.py +51 -31
  38. gitflow_analytics/reports/narrative_writer.py +16 -14
  39. gitflow_analytics/tui/__init__.py +5 -0
  40. gitflow_analytics/tui/app.py +721 -0
  41. gitflow_analytics/tui/screens/__init__.py +8 -0
  42. gitflow_analytics/tui/screens/analysis_progress_screen.py +487 -0
  43. gitflow_analytics/tui/screens/configuration_screen.py +547 -0
  44. gitflow_analytics/tui/screens/loading_screen.py +358 -0
  45. gitflow_analytics/tui/screens/main_screen.py +304 -0
  46. gitflow_analytics/tui/screens/results_screen.py +698 -0
  47. gitflow_analytics/tui/widgets/__init__.py +7 -0
  48. gitflow_analytics/tui/widgets/data_table.py +257 -0
  49. gitflow_analytics/tui/widgets/export_modal.py +301 -0
  50. gitflow_analytics/tui/widgets/progress_widget.py +192 -0
  51. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/METADATA +31 -4
  52. gitflow_analytics-1.0.3.dist-info/RECORD +62 -0
  53. gitflow_analytics-1.0.1.dist-info/RECORD +0 -31
  54. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/WHEEL +0 -0
  55. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/entry_points.txt +0 -0
  56. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/licenses/LICENSE +0 -0
  57. {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