git-llm-tool 0.1.12__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.
- git_llm_tool/__init__.py +5 -0
- git_llm_tool/__main__.py +6 -0
- git_llm_tool/cli.py +167 -0
- git_llm_tool/commands/__init__.py +1 -0
- git_llm_tool/commands/changelog_cmd.py +189 -0
- git_llm_tool/commands/commit_cmd.py +134 -0
- git_llm_tool/core/__init__.py +1 -0
- git_llm_tool/core/config.py +352 -0
- git_llm_tool/core/diff_optimizer.py +206 -0
- git_llm_tool/core/exceptions.py +26 -0
- git_llm_tool/core/git_helper.py +250 -0
- git_llm_tool/core/jira_helper.py +238 -0
- git_llm_tool/core/rate_limiter.py +136 -0
- git_llm_tool/core/smart_chunker.py +262 -0
- git_llm_tool/core/token_counter.py +169 -0
- git_llm_tool/providers/__init__.py +21 -0
- git_llm_tool/providers/anthropic_langchain.py +42 -0
- git_llm_tool/providers/azure_openai_langchain.py +59 -0
- git_llm_tool/providers/base.py +203 -0
- git_llm_tool/providers/factory.py +85 -0
- git_llm_tool/providers/gemini_langchain.py +57 -0
- git_llm_tool/providers/langchain_base.py +608 -0
- git_llm_tool/providers/ollama_langchain.py +45 -0
- git_llm_tool/providers/openai_langchain.py +42 -0
- git_llm_tool-0.1.12.dist-info/LICENSE +21 -0
- git_llm_tool-0.1.12.dist-info/METADATA +645 -0
- git_llm_tool-0.1.12.dist-info/RECORD +29 -0
- git_llm_tool-0.1.12.dist-info/WHEEL +4 -0
- git_llm_tool-0.1.12.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""LangChain base provider implementation."""
|
|
2
|
+
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import time
|
|
7
|
+
import asyncio
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
|
|
10
|
+
from halo import Halo
|
|
11
|
+
from langchain_core.documents import Document
|
|
12
|
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
|
13
|
+
from langchain_core.language_models import BaseLanguageModel
|
|
14
|
+
from langchain_ollama import OllamaLLM
|
|
15
|
+
|
|
16
|
+
from git_llm_tool.core.config import AppConfig
|
|
17
|
+
from git_llm_tool.core.exceptions import ApiError
|
|
18
|
+
from git_llm_tool.providers.base import LlmProvider
|
|
19
|
+
from git_llm_tool.core.rate_limiter import RateLimiter, RateLimitConfig
|
|
20
|
+
from git_llm_tool.core.diff_optimizer import DiffOptimizer
|
|
21
|
+
from git_llm_tool.core.smart_chunker import SmartChunker
|
|
22
|
+
from git_llm_tool.core.token_counter import TokenCounter
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ChunkStats:
|
|
27
|
+
"""Statistics about diff chunking process."""
|
|
28
|
+
original_size: int
|
|
29
|
+
num_chunks: int
|
|
30
|
+
chunking_used: bool
|
|
31
|
+
processing_time: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LangChainProvider(LlmProvider):
|
|
35
|
+
"""Base class for LangChain-based LLM providers."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: AppConfig, llm: Optional[BaseLanguageModel] = None):
|
|
38
|
+
"""Initialize the LangChain provider."""
|
|
39
|
+
super().__init__(config)
|
|
40
|
+
|
|
41
|
+
# Set the main LLM (provided by subclass or create one)
|
|
42
|
+
self.llm = llm if llm is not None else self._create_llm()
|
|
43
|
+
|
|
44
|
+
# Initialize Ollama LLM for chunk processing if enabled
|
|
45
|
+
self.ollama_llm = None
|
|
46
|
+
if config.llm.use_ollama_for_chunks:
|
|
47
|
+
try:
|
|
48
|
+
self.ollama_llm = OllamaLLM(
|
|
49
|
+
model=config.llm.ollama_model,
|
|
50
|
+
base_url=config.llm.ollama_base_url,
|
|
51
|
+
temperature=0.1
|
|
52
|
+
)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
# If Ollama is not available, fall back to main LLM
|
|
55
|
+
print(f"⚠️ Ollama not available, using main LLM for chunks: {e}")
|
|
56
|
+
self.ollama_llm = None
|
|
57
|
+
|
|
58
|
+
# Always initialize rate limiter (simplified - always enabled)
|
|
59
|
+
rate_config = RateLimitConfig(
|
|
60
|
+
max_retries=config.llm._max_retries,
|
|
61
|
+
initial_delay=config.llm._initial_delay,
|
|
62
|
+
max_delay=config.llm._max_delay,
|
|
63
|
+
backoff_multiplier=config.llm._backoff_multiplier,
|
|
64
|
+
rate_limit_delay=config.llm._rate_limit_delay
|
|
65
|
+
)
|
|
66
|
+
self.rate_limiter = RateLimiter(rate_config)
|
|
67
|
+
|
|
68
|
+
# Always initialize diff optimizer (simplified - always enabled)
|
|
69
|
+
self.diff_optimizer = DiffOptimizer(
|
|
70
|
+
max_context_lines=config.llm._max_context_lines
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Always initialize token counter
|
|
74
|
+
self.token_counter = TokenCounter(config.llm.default_model)
|
|
75
|
+
|
|
76
|
+
# Always initialize smart chunker (will be used based on threshold)
|
|
77
|
+
self.smart_chunker = SmartChunker(
|
|
78
|
+
chunk_size=config.llm._chunk_size,
|
|
79
|
+
chunk_overlap=config.llm._chunk_overlap
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Keep fallback text splitter for edge cases
|
|
83
|
+
self.text_splitter = RecursiveCharacterTextSplitter(
|
|
84
|
+
chunk_size=config.llm._chunk_size,
|
|
85
|
+
chunk_overlap=config.llm._chunk_overlap,
|
|
86
|
+
separators=[
|
|
87
|
+
"\n\ndiff --git", # Git diff file separators
|
|
88
|
+
"\n@@", # Git diff hunk separators
|
|
89
|
+
"\n+", # Added lines
|
|
90
|
+
"\n-", # Removed lines
|
|
91
|
+
"\n", # General newlines
|
|
92
|
+
" ", # Spaces
|
|
93
|
+
"", # Characters
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def _create_llm(self) -> BaseLanguageModel:
|
|
99
|
+
"""Create the specific LangChain LLM instance.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Configured LangChain LLM instance
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ApiError: If LLM creation fails
|
|
106
|
+
"""
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
def generate_commit_message(
|
|
110
|
+
self,
|
|
111
|
+
diff: str,
|
|
112
|
+
jira_ticket: Optional[str] = None,
|
|
113
|
+
work_hours: Optional[str] = None,
|
|
114
|
+
**kwargs
|
|
115
|
+
) -> str:
|
|
116
|
+
"""Generate commit message using LangChain with optimizations."""
|
|
117
|
+
start_time = time.time()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
# Step 1: Pre-optimize diff if enabled
|
|
121
|
+
optimized_diff = diff
|
|
122
|
+
# Simple decision: chunking threshold determines everything
|
|
123
|
+
diff_tokens = self.token_counter.count_tokens(diff)
|
|
124
|
+
will_chunk = diff_tokens > self.config.llm.chunking_threshold
|
|
125
|
+
|
|
126
|
+
# Use lighter optimization if we will chunk (preserve diff structure)
|
|
127
|
+
# Use more aggressive optimization if we won't chunk (save tokens)
|
|
128
|
+
use_aggressive = not will_chunk
|
|
129
|
+
|
|
130
|
+
optimized_diff, diff_stats = self.diff_optimizer.optimize_diff(
|
|
131
|
+
diff,
|
|
132
|
+
aggressive=use_aggressive
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if kwargs.get("verbose", False):
|
|
136
|
+
print(f"📊 Diff pre-optimization (aggressive={use_aggressive}):")
|
|
137
|
+
print(f" Original size: {diff_stats.original_size:,} chars")
|
|
138
|
+
print(f" Optimized size: {diff_stats.optimized_size:,} chars")
|
|
139
|
+
print(f" Compression ratio: {diff_stats.compression_ratio:.2f}")
|
|
140
|
+
print(f" Files processed: {diff_stats.files_processed}")
|
|
141
|
+
|
|
142
|
+
# If not chunking and still too large, apply smart truncation using accurate token count
|
|
143
|
+
current_tokens = self.token_counter.count_tokens(optimized_diff)
|
|
144
|
+
if not will_chunk and current_tokens > self.config.llm._max_tokens:
|
|
145
|
+
if kwargs.get("verbose", False):
|
|
146
|
+
print(f" Current tokens: {current_tokens:,} (exceeds limit: {self.config.llm._max_tokens:,})")
|
|
147
|
+
|
|
148
|
+
optimized_diff = self.token_counter.truncate_to_tokens(
|
|
149
|
+
optimized_diff,
|
|
150
|
+
self.config.llm._max_tokens
|
|
151
|
+
)
|
|
152
|
+
new_tokens = self.token_counter.count_tokens(optimized_diff)
|
|
153
|
+
if kwargs.get("verbose", False):
|
|
154
|
+
print(f" Truncated to: {new_tokens:,} tokens ({len(optimized_diff):,} chars)")
|
|
155
|
+
|
|
156
|
+
# Step 2: Simple decision - use threshold to decide processing strategy
|
|
157
|
+
optimized_tokens = self.token_counter.count_tokens(optimized_diff)
|
|
158
|
+
should_chunk = optimized_tokens > self.config.llm.chunking_threshold
|
|
159
|
+
|
|
160
|
+
if should_chunk:
|
|
161
|
+
if kwargs.get("verbose", False):
|
|
162
|
+
print(f"🔄 Using intelligent chunking strategy for large diff")
|
|
163
|
+
print(f" Diff tokens: {optimized_tokens:,} (threshold: {self.config.llm.chunking_threshold:,})")
|
|
164
|
+
result = self._generate_with_smart_chunking(optimized_diff, jira_ticket, work_hours, **kwargs)
|
|
165
|
+
else:
|
|
166
|
+
if kwargs.get("verbose", False):
|
|
167
|
+
print(f"✨ Using direct processing")
|
|
168
|
+
print(f" Diff tokens: {optimized_tokens:,} (under threshold: {self.config.llm.chunking_threshold:,})")
|
|
169
|
+
result = self._generate_simple(optimized_diff, jira_ticket, work_hours, **kwargs)
|
|
170
|
+
|
|
171
|
+
processing_time = time.time() - start_time
|
|
172
|
+
if kwargs.get("verbose", False):
|
|
173
|
+
print(f"⏱️ Total processing time: {processing_time:.2f}s")
|
|
174
|
+
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
raise ApiError(f"LangChain provider error: {e}")
|
|
179
|
+
|
|
180
|
+
def _generate_simple(
|
|
181
|
+
self,
|
|
182
|
+
diff: str,
|
|
183
|
+
jira_ticket: Optional[str] = None,
|
|
184
|
+
work_hours: Optional[str] = None,
|
|
185
|
+
**kwargs
|
|
186
|
+
) -> str:
|
|
187
|
+
"""Generate commit message without chunking."""
|
|
188
|
+
prompt = self._build_commit_prompt(diff, jira_ticket, work_hours)
|
|
189
|
+
|
|
190
|
+
def _make_llm_call():
|
|
191
|
+
"""Internal function to make the LLM call."""
|
|
192
|
+
return self.llm.invoke(prompt, **kwargs)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
# Show progress for simple processing too
|
|
196
|
+
with Halo(text="🤖 Generating commit message...", spinner="dots") as spinner:
|
|
197
|
+
# Use rate limiter if available
|
|
198
|
+
if self.rate_limiter:
|
|
199
|
+
response = self.rate_limiter.retry_with_backoff(_make_llm_call)
|
|
200
|
+
else:
|
|
201
|
+
response = _make_llm_call()
|
|
202
|
+
|
|
203
|
+
spinner.succeed("✅ Commit message generated successfully")
|
|
204
|
+
|
|
205
|
+
# Handle different response types
|
|
206
|
+
if hasattr(response, 'content'):
|
|
207
|
+
return response.content.strip()
|
|
208
|
+
elif isinstance(response, str):
|
|
209
|
+
return response.strip()
|
|
210
|
+
else:
|
|
211
|
+
return str(response).strip()
|
|
212
|
+
|
|
213
|
+
except Exception as e:
|
|
214
|
+
raise ApiError(f"Failed to generate commit message: {e}")
|
|
215
|
+
|
|
216
|
+
def _generate_with_smart_chunking(
|
|
217
|
+
self,
|
|
218
|
+
diff: str,
|
|
219
|
+
jira_ticket: Optional[str] = None,
|
|
220
|
+
work_hours: Optional[str] = None,
|
|
221
|
+
**kwargs
|
|
222
|
+
) -> str:
|
|
223
|
+
"""Generate commit message using smart chunking and rate limiting."""
|
|
224
|
+
verbose = kwargs.get("verbose", False)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
# Show chunking progress
|
|
228
|
+
with Halo(text="🔄 Analyzing diff and creating intelligent chunks...", spinner="dots") as spinner:
|
|
229
|
+
chunk_infos = self.smart_chunker.chunk_diff(diff)
|
|
230
|
+
docs = self.smart_chunker.chunks_to_documents(chunk_infos)
|
|
231
|
+
spinner.succeed(f"✅ Created {len(docs)} intelligent chunks")
|
|
232
|
+
|
|
233
|
+
if verbose:
|
|
234
|
+
stats = self.smart_chunker.get_chunking_stats(chunk_infos)
|
|
235
|
+
print(f"📄 Smart chunking stats:")
|
|
236
|
+
print(f" Total chunks: {stats['total_chunks']}")
|
|
237
|
+
print(f" File chunks: {stats['file_chunks']}")
|
|
238
|
+
print(f" Hunk chunks: {stats['hunk_chunks']}")
|
|
239
|
+
print(f" Size-based chunks: {stats['size_based_chunks']}")
|
|
240
|
+
print(f" Complete files: {stats['complete_files']}")
|
|
241
|
+
print(f" Average chunk size: {stats['average_chunk_size']:,} chars")
|
|
242
|
+
|
|
243
|
+
# Show hybrid processing info
|
|
244
|
+
if self.ollama_llm is not None:
|
|
245
|
+
print(f"🔄 Hybrid processing mode:")
|
|
246
|
+
print(f" Map phase (chunks): Ollama ({self.config.llm.ollama_model})")
|
|
247
|
+
print(f" Reduce phase (final): {self.config.llm.default_model}")
|
|
248
|
+
else:
|
|
249
|
+
print(f"🔄 Standard processing mode:")
|
|
250
|
+
print(f" All phases: {self.config.llm.default_model}")
|
|
251
|
+
|
|
252
|
+
# Use manual map-reduce to avoid dict concatenation issues
|
|
253
|
+
if len(docs) == 1:
|
|
254
|
+
# Single chunk, use direct processing
|
|
255
|
+
with Halo(text="🤖 Generating commit message for single chunk...", spinner="dots") as spinner:
|
|
256
|
+
prompt = self._build_commit_prompt(docs[0].page_content, jira_ticket, work_hours)
|
|
257
|
+
|
|
258
|
+
def _make_single_call():
|
|
259
|
+
return self.llm.invoke(prompt)
|
|
260
|
+
|
|
261
|
+
if self.rate_limiter:
|
|
262
|
+
response = self.rate_limiter.retry_with_backoff(_make_single_call)
|
|
263
|
+
else:
|
|
264
|
+
response = _make_single_call()
|
|
265
|
+
|
|
266
|
+
# Handle response
|
|
267
|
+
if hasattr(response, 'content'):
|
|
268
|
+
result = response.content.strip()
|
|
269
|
+
elif isinstance(response, str):
|
|
270
|
+
result = response.strip()
|
|
271
|
+
else:
|
|
272
|
+
result = str(response).strip()
|
|
273
|
+
|
|
274
|
+
spinner.succeed("✅ Commit message generated successfully")
|
|
275
|
+
return result
|
|
276
|
+
else:
|
|
277
|
+
# Multiple chunks, use manual map-reduce
|
|
278
|
+
return self._manual_map_reduce(docs, jira_ticket, work_hours, **kwargs)
|
|
279
|
+
|
|
280
|
+
except Exception as e:
|
|
281
|
+
raise ApiError(f"Failed to generate commit message with smart chunking: {e}")
|
|
282
|
+
|
|
283
|
+
def _manual_map_reduce(
|
|
284
|
+
self,
|
|
285
|
+
docs: List[Document],
|
|
286
|
+
jira_ticket: Optional[str] = None,
|
|
287
|
+
work_hours: Optional[str] = None,
|
|
288
|
+
**kwargs
|
|
289
|
+
) -> str:
|
|
290
|
+
"""Manual map-reduce implementation with parallel processing."""
|
|
291
|
+
try:
|
|
292
|
+
# Improved logic: use parallel if Ollama is enabled OR we have multiple docs
|
|
293
|
+
use_parallel = (self.ollama_llm is not None) or (len(docs) > 1)
|
|
294
|
+
|
|
295
|
+
if use_parallel:
|
|
296
|
+
return self._parallel_map_reduce(docs, jira_ticket, work_hours, **kwargs)
|
|
297
|
+
else:
|
|
298
|
+
return self._sequential_map_reduce(docs, jira_ticket, work_hours, **kwargs)
|
|
299
|
+
|
|
300
|
+
except Exception as e:
|
|
301
|
+
raise ApiError(f"Manual map-reduce failed: {e}")
|
|
302
|
+
|
|
303
|
+
def _parallel_map_reduce(
|
|
304
|
+
self,
|
|
305
|
+
docs: List[Document],
|
|
306
|
+
jira_ticket: Optional[str] = None,
|
|
307
|
+
work_hours: Optional[str] = None,
|
|
308
|
+
**kwargs
|
|
309
|
+
) -> str:
|
|
310
|
+
"""Parallel map-reduce implementation for faster processing."""
|
|
311
|
+
verbose = kwargs.get("verbose", False)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
# Map phase: Process chunks in parallel
|
|
315
|
+
summaries = [""] * len(docs) # Pre-allocate to maintain order
|
|
316
|
+
|
|
317
|
+
def process_chunk(index_doc_pair):
|
|
318
|
+
"""Process a single chunk."""
|
|
319
|
+
i, doc = index_doc_pair
|
|
320
|
+
try:
|
|
321
|
+
if kwargs.get("verbose", False):
|
|
322
|
+
print(f"📝 Processing chunk {i+1}/{len(docs)} in parallel...")
|
|
323
|
+
|
|
324
|
+
# Create map prompt for this chunk
|
|
325
|
+
map_prompt = self._create_simple_map_prompt(doc.page_content)
|
|
326
|
+
|
|
327
|
+
def _make_map_call():
|
|
328
|
+
# Use Ollama for chunk processing if available, otherwise use main LLM
|
|
329
|
+
if self.ollama_llm is not None:
|
|
330
|
+
return self.ollama_llm.invoke(map_prompt)
|
|
331
|
+
else:
|
|
332
|
+
return self.llm.invoke(map_prompt)
|
|
333
|
+
|
|
334
|
+
# Execute with rate limiting
|
|
335
|
+
if self.rate_limiter:
|
|
336
|
+
response = self.rate_limiter.retry_with_backoff(_make_map_call)
|
|
337
|
+
else:
|
|
338
|
+
response = _make_map_call()
|
|
339
|
+
|
|
340
|
+
# Extract text from response
|
|
341
|
+
if hasattr(response, 'content'):
|
|
342
|
+
summary = response.content.strip()
|
|
343
|
+
elif isinstance(response, str):
|
|
344
|
+
summary = response.strip()
|
|
345
|
+
else:
|
|
346
|
+
summary = str(response).strip()
|
|
347
|
+
|
|
348
|
+
if kwargs.get("verbose", False):
|
|
349
|
+
print(f" ✅ Chunk {i+1} completed ({len(summary)} chars)")
|
|
350
|
+
|
|
351
|
+
return i, summary
|
|
352
|
+
|
|
353
|
+
except Exception as e:
|
|
354
|
+
if kwargs.get("verbose", False):
|
|
355
|
+
print(f" ❌ Chunk {i+1} failed: {e}")
|
|
356
|
+
return i, f"Error processing chunk: {str(e)}"
|
|
357
|
+
|
|
358
|
+
# Execute parallel processing with configurable worker count
|
|
359
|
+
if self.ollama_llm is not None:
|
|
360
|
+
# Ollama is local, use configured Ollama concurrency
|
|
361
|
+
max_workers = min(self.config.llm.ollama_max_parallel_chunks, len(docs))
|
|
362
|
+
else:
|
|
363
|
+
# Remote API, use configured remote API concurrency
|
|
364
|
+
max_workers = min(self.config.llm.max_parallel_chunks, len(docs))
|
|
365
|
+
completed_chunks = 0
|
|
366
|
+
|
|
367
|
+
with Halo(text=f"🚀 Starting {max_workers} parallel workers for {len(docs)} chunks...", spinner="dots") as spinner:
|
|
368
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
369
|
+
# Submit all tasks
|
|
370
|
+
future_to_index = {
|
|
371
|
+
executor.submit(process_chunk, (i, doc)): i
|
|
372
|
+
for i, doc in enumerate(docs)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
# Collect results as they complete
|
|
376
|
+
for future in as_completed(future_to_index, timeout=self.config.llm.chunk_processing_timeout):
|
|
377
|
+
try:
|
|
378
|
+
index, summary = future.result()
|
|
379
|
+
summaries[index] = summary
|
|
380
|
+
completed_chunks += 1
|
|
381
|
+
|
|
382
|
+
# Update spinner text with progress - show parallel workers in action
|
|
383
|
+
progress_percent = (completed_chunks / len(docs)) * 100
|
|
384
|
+
spinner.text = f"🚀 Parallel processing: {completed_chunks}/{len(docs)} chunks completed ({progress_percent:.1f}%) [{max_workers} workers]"
|
|
385
|
+
|
|
386
|
+
if verbose and not summary.startswith("Error"):
|
|
387
|
+
spinner.text += f" ✅ Chunk {index+1}"
|
|
388
|
+
|
|
389
|
+
except Exception as e:
|
|
390
|
+
# Handle individual chunk failures
|
|
391
|
+
index = future_to_index[future]
|
|
392
|
+
summaries[index] = f"Chunk processing failed: {str(e)}"
|
|
393
|
+
completed_chunks += 1
|
|
394
|
+
|
|
395
|
+
progress_percent = (completed_chunks / len(docs)) * 100
|
|
396
|
+
spinner.text = f"🚀 Parallel processing: {completed_chunks}/{len(docs)} chunks completed ({progress_percent:.1f}%) [{max_workers} workers]"
|
|
397
|
+
if verbose:
|
|
398
|
+
spinner.text += f" ❌ Chunk {index+1} failed"
|
|
399
|
+
|
|
400
|
+
successful_chunks = len([s for s in summaries if not s.startswith("Error") and not s.startswith("Chunk processing failed")])
|
|
401
|
+
spinner.succeed(f"✅ Parallel processing completed with {max_workers} workers: {successful_chunks}/{len(docs)} chunks successful")
|
|
402
|
+
|
|
403
|
+
# Reduce phase: Combine summaries into final commit message
|
|
404
|
+
with Halo(text=f"🔄 Combining {len(summaries)} summaries into final commit message...", spinner="dots") as spinner:
|
|
405
|
+
combined_summary = "\n\n".join([f"Part {i+1}: {summary}" for i, summary in enumerate(summaries)])
|
|
406
|
+
combine_prompt = self._create_combine_prompt(combined_summary, jira_ticket, work_hours)
|
|
407
|
+
|
|
408
|
+
def _make_combine_call():
|
|
409
|
+
return self.llm.invoke(combine_prompt)
|
|
410
|
+
|
|
411
|
+
# Execute final combination with rate limiting
|
|
412
|
+
if self.rate_limiter:
|
|
413
|
+
final_response = self.rate_limiter.retry_with_backoff(_make_combine_call)
|
|
414
|
+
else:
|
|
415
|
+
final_response = _make_combine_call()
|
|
416
|
+
|
|
417
|
+
spinner.succeed("✅ Final commit message generated successfully")
|
|
418
|
+
|
|
419
|
+
# Extract final result
|
|
420
|
+
if hasattr(final_response, 'content'):
|
|
421
|
+
return final_response.content.strip()
|
|
422
|
+
elif isinstance(final_response, str):
|
|
423
|
+
return final_response.strip()
|
|
424
|
+
else:
|
|
425
|
+
return str(final_response).strip()
|
|
426
|
+
|
|
427
|
+
except Exception as e:
|
|
428
|
+
if kwargs.get("verbose", False):
|
|
429
|
+
print(f"❌ Parallel processing failed, falling back to sequential: {e}")
|
|
430
|
+
return self._sequential_map_reduce(docs, jira_ticket, work_hours, **kwargs)
|
|
431
|
+
|
|
432
|
+
def _sequential_map_reduce(
|
|
433
|
+
self,
|
|
434
|
+
docs: List[Document],
|
|
435
|
+
jira_ticket: Optional[str] = None,
|
|
436
|
+
work_hours: Optional[str] = None,
|
|
437
|
+
**kwargs
|
|
438
|
+
) -> str:
|
|
439
|
+
"""Sequential map-reduce implementation (fallback)."""
|
|
440
|
+
try:
|
|
441
|
+
# Map phase: Summarize each chunk sequentially
|
|
442
|
+
summaries = []
|
|
443
|
+
|
|
444
|
+
with Halo(text=f"⏳ Processing {len(docs)} chunks sequentially (0/{len(docs)} completed)...", spinner="dots") as spinner:
|
|
445
|
+
for i, doc in enumerate(docs):
|
|
446
|
+
spinner.text = f"⏳ Processing chunk {i+1}/{len(docs)} sequentially..."
|
|
447
|
+
|
|
448
|
+
# Create map prompt for this chunk
|
|
449
|
+
map_prompt = self._create_simple_map_prompt(doc.page_content)
|
|
450
|
+
|
|
451
|
+
def _make_map_call():
|
|
452
|
+
# Use Ollama for chunk processing if available, otherwise use main LLM
|
|
453
|
+
if self.ollama_llm is not None:
|
|
454
|
+
return self.ollama_llm.invoke(map_prompt)
|
|
455
|
+
else:
|
|
456
|
+
return self.llm.invoke(map_prompt)
|
|
457
|
+
|
|
458
|
+
# Execute with rate limiting
|
|
459
|
+
if self.rate_limiter:
|
|
460
|
+
response = self.rate_limiter.retry_with_backoff(_make_map_call)
|
|
461
|
+
else:
|
|
462
|
+
response = _make_map_call()
|
|
463
|
+
|
|
464
|
+
# Extract text from response
|
|
465
|
+
if hasattr(response, 'content'):
|
|
466
|
+
summary = response.content.strip()
|
|
467
|
+
elif isinstance(response, str):
|
|
468
|
+
summary = response.strip()
|
|
469
|
+
else:
|
|
470
|
+
summary = str(response).strip()
|
|
471
|
+
|
|
472
|
+
summaries.append(summary)
|
|
473
|
+
spinner.text = f"⏳ Processing {len(docs)} chunks sequentially ({i+1}/{len(docs)} completed)..."
|
|
474
|
+
|
|
475
|
+
spinner.succeed(f"✅ Sequential processing completed for {len(docs)} chunks")
|
|
476
|
+
|
|
477
|
+
# Reduce phase: Combine summaries into final commit message
|
|
478
|
+
with Halo(text=f"🔄 Combining {len(summaries)} summaries into final commit message...", spinner="dots") as spinner:
|
|
479
|
+
combined_summary = "\n\n".join([f"Part {i+1}: {summary}" for i, summary in enumerate(summaries)])
|
|
480
|
+
combine_prompt = self._create_combine_prompt(combined_summary, jira_ticket, work_hours)
|
|
481
|
+
|
|
482
|
+
def _make_combine_call():
|
|
483
|
+
return self.llm.invoke(combine_prompt)
|
|
484
|
+
|
|
485
|
+
# Execute final combination with rate limiting
|
|
486
|
+
if self.rate_limiter:
|
|
487
|
+
final_response = self.rate_limiter.retry_with_backoff(_make_combine_call)
|
|
488
|
+
else:
|
|
489
|
+
final_response = _make_combine_call()
|
|
490
|
+
|
|
491
|
+
spinner.succeed("✅ Final commit message generated successfully")
|
|
492
|
+
|
|
493
|
+
# Extract final result
|
|
494
|
+
if hasattr(final_response, 'content'):
|
|
495
|
+
return final_response.content.strip()
|
|
496
|
+
elif isinstance(final_response, str):
|
|
497
|
+
return final_response.strip()
|
|
498
|
+
else:
|
|
499
|
+
return str(final_response).strip()
|
|
500
|
+
|
|
501
|
+
except Exception as e:
|
|
502
|
+
raise ApiError(f"Sequential map-reduce failed: {e}")
|
|
503
|
+
|
|
504
|
+
def _create_simple_map_prompt(self, chunk_content: str) -> str:
|
|
505
|
+
"""Create a simple prompt for analyzing a diff chunk."""
|
|
506
|
+
return f"""Analyze this part of a git diff and summarize the changes in {self.config.llm.language}.
|
|
507
|
+
|
|
508
|
+
Focus on:
|
|
509
|
+
- What type of changes (feat, fix, docs, style, refactor, test, chore)
|
|
510
|
+
- Key modifications made
|
|
511
|
+
- Files or components affected
|
|
512
|
+
|
|
513
|
+
Git diff part:
|
|
514
|
+
{chunk_content}
|
|
515
|
+
|
|
516
|
+
Summary of changes in this part:"""
|
|
517
|
+
|
|
518
|
+
def _create_combine_prompt(
|
|
519
|
+
self,
|
|
520
|
+
summaries: str,
|
|
521
|
+
jira_ticket: Optional[str] = None,
|
|
522
|
+
work_hours: Optional[str] = None
|
|
523
|
+
) -> str:
|
|
524
|
+
"""Create prompt for combining summaries into final commit message."""
|
|
525
|
+
|
|
526
|
+
# Determine the output format based on Jira ticket
|
|
527
|
+
if jira_ticket:
|
|
528
|
+
format_instructions = f"""
|
|
529
|
+
Generate the commit message in this **exact format**:
|
|
530
|
+
{jira_ticket} <summary>""" + (f" #time {work_hours}" if work_hours else "") + """
|
|
531
|
+
- feat: detailed description of new features
|
|
532
|
+
- fix: detailed description of bug fixes
|
|
533
|
+
- docs: detailed description of documentation changes
|
|
534
|
+
(include only the types that apply to your changes)"""
|
|
535
|
+
else:
|
|
536
|
+
format_instructions = """
|
|
537
|
+
Generate the commit message in this **exact format**:
|
|
538
|
+
<summary>
|
|
539
|
+
- feat: description of new features
|
|
540
|
+
- fix: description of bug fixes
|
|
541
|
+
- docs: description of documentation changes
|
|
542
|
+
(include only the types that apply to your changes)"""
|
|
543
|
+
|
|
544
|
+
return f"""Based on the following summaries of different parts of a git diff,
|
|
545
|
+
generate a concise commit message in {self.config.llm.language}.
|
|
546
|
+
|
|
547
|
+
**Conventional Commit types**:
|
|
548
|
+
- feat: new feature
|
|
549
|
+
- fix: bug fix
|
|
550
|
+
- docs: documentation changes
|
|
551
|
+
- style: formatting, missing semicolons, etc
|
|
552
|
+
- refactor: code restructuring without changing functionality
|
|
553
|
+
- test: adding or modifying tests
|
|
554
|
+
- chore: maintenance tasks
|
|
555
|
+
|
|
556
|
+
Part summaries:
|
|
557
|
+
{summaries}
|
|
558
|
+
|
|
559
|
+
{format_instructions}
|
|
560
|
+
|
|
561
|
+
Generate ONLY the commit message in the specified format, no additional text or explanation."""
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def generate_changelog(
|
|
566
|
+
self,
|
|
567
|
+
commit_messages: list[str],
|
|
568
|
+
**kwargs
|
|
569
|
+
) -> str:
|
|
570
|
+
"""Generate changelog using LangChain."""
|
|
571
|
+
prompt = self._build_changelog_prompt(commit_messages)
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
response = self.llm.invoke(prompt, **kwargs)
|
|
575
|
+
|
|
576
|
+
if hasattr(response, 'content'):
|
|
577
|
+
return response.content.strip()
|
|
578
|
+
elif isinstance(response, str):
|
|
579
|
+
return response.strip()
|
|
580
|
+
else:
|
|
581
|
+
return str(response).strip()
|
|
582
|
+
|
|
583
|
+
except Exception as e:
|
|
584
|
+
raise ApiError(f"Failed to generate changelog: {e}")
|
|
585
|
+
|
|
586
|
+
def _should_chunk(self, text: str) -> bool:
|
|
587
|
+
"""Determine if text should be chunked based on token count and configuration."""
|
|
588
|
+
text_tokens = self.token_counter.count_tokens(text)
|
|
589
|
+
return text_tokens > self.config.llm.chunking_threshold
|
|
590
|
+
|
|
591
|
+
def _make_api_call(self, prompt: str, **kwargs) -> str:
|
|
592
|
+
"""Make API call using LangChain's unified interface.
|
|
593
|
+
|
|
594
|
+
This method is required by the base LlmProvider class but is handled
|
|
595
|
+
internally by the LangChain LLM instances in this implementation.
|
|
596
|
+
"""
|
|
597
|
+
try:
|
|
598
|
+
response = self.llm.invoke(prompt, **kwargs)
|
|
599
|
+
|
|
600
|
+
if hasattr(response, 'content'):
|
|
601
|
+
return response.content.strip()
|
|
602
|
+
elif isinstance(response, str):
|
|
603
|
+
return response.strip()
|
|
604
|
+
else:
|
|
605
|
+
return str(response).strip()
|
|
606
|
+
|
|
607
|
+
except Exception as e:
|
|
608
|
+
raise ApiError(f"LangChain API call failed: {e}")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Ollama LangChain provider implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from langchain_ollama import OllamaLLM
|
|
5
|
+
|
|
6
|
+
from git_llm_tool.core.config import AppConfig
|
|
7
|
+
from git_llm_tool.core.exceptions import ApiError
|
|
8
|
+
from git_llm_tool.providers.langchain_base import LangChainProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OllamaLangChainProvider(LangChainProvider):
|
|
12
|
+
"""Ollama provider using LangChain."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config: AppConfig):
|
|
15
|
+
"""Initialize Ollama provider."""
|
|
16
|
+
try:
|
|
17
|
+
# Initialize the Ollama LLM
|
|
18
|
+
llm = OllamaLLM(
|
|
19
|
+
model=config.llm.ollama_model,
|
|
20
|
+
base_url=config.llm.ollama_base_url,
|
|
21
|
+
temperature=0.1 # Keep it deterministic for commit messages
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
super().__init__(config, llm)
|
|
25
|
+
|
|
26
|
+
except Exception as e:
|
|
27
|
+
raise ApiError(f"Failed to initialize Ollama provider: {e}")
|
|
28
|
+
|
|
29
|
+
def validate_config(self) -> bool:
|
|
30
|
+
"""Validate Ollama configuration."""
|
|
31
|
+
try:
|
|
32
|
+
# Test connection to Ollama
|
|
33
|
+
response = self.llm.invoke("test")
|
|
34
|
+
return True
|
|
35
|
+
except Exception as e:
|
|
36
|
+
raise ApiError(f"Ollama validation failed: {e}")
|
|
37
|
+
|
|
38
|
+
def get_model_info(self) -> dict:
|
|
39
|
+
"""Get information about the current model."""
|
|
40
|
+
return {
|
|
41
|
+
"provider": "ollama",
|
|
42
|
+
"model": self.config.llm.ollama_model,
|
|
43
|
+
"base_url": self.config.llm.ollama_base_url,
|
|
44
|
+
"local": True
|
|
45
|
+
}
|