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.
@@ -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
+ }