(feature): BaseFileLogger idempotent log folder creation and tests
This commit is contained in:
parent
1bfff2de76
commit
53b51223e4
12
CHANGELOG.md
12
CHANGELOG.md
@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## v1.6.1
|
## v1.6.2 - 2026-02-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `BaseFileLogger` idempotent log folder creation and tests
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improved `BaseFileLogger` to ensure log folder is recreated if deleted during runtime (idempotent folder creation).
|
||||||
|
- Added comprehensive tests verifying log folder recreation and robustness against folder deletion scenarios.
|
||||||
|
- Removed AI assisted CHANGELOG.md generation as it's weak and not worth the effort.
|
||||||
|
|
||||||
|
## v1.6.1 - 2026-31-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Added `CreateMutex` method to `BaseFileLogger`
|
- Added `CreateMutex` method to `BaseFileLogger`
|
||||||
|
|||||||
@ -217,4 +217,42 @@ public class FileLoggerTests {
|
|||||||
var logContent = File.ReadAllText(logFile);
|
var logContent = File.ReadAllText(logFile);
|
||||||
Assert.Contains("Empty folder prefix log message", logContent);
|
Assert.Contains("Empty folder prefix log message", logContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldRecreateLogFolderIfDeleted() {
|
||||||
|
// Arrange
|
||||||
|
var serviceCollection = new ServiceCollection();
|
||||||
|
serviceCollection.AddSingleton<IHostEnvironment>(sp =>
|
||||||
|
new TestHostEnvironment {
|
||||||
|
EnvironmentName = Environments.Development,
|
||||||
|
ApplicationName = "TestApp",
|
||||||
|
ContentRootPath = Directory.GetCurrentDirectory()
|
||||||
|
});
|
||||||
|
|
||||||
|
serviceCollection.AddLogging(builder => builder.AddFileLogger(_testFolderPath, TimeSpan.FromDays(7)));
|
||||||
|
|
||||||
|
var provider = serviceCollection.BuildServiceProvider();
|
||||||
|
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
|
||||||
|
|
||||||
|
// Act - Create logger and write a log (folder is created)
|
||||||
|
var logger = loggerFactory.CreateLogger(LoggerPrefix.Folder.WithValue("Audit"));
|
||||||
|
logger.LogInformation("First log message");
|
||||||
|
|
||||||
|
var auditFolder = Path.Combine(_testFolderPath, "Audit");
|
||||||
|
Assert.True(Directory.Exists(auditFolder), "Audit subfolder should be created");
|
||||||
|
|
||||||
|
// Delete the folder
|
||||||
|
Directory.Delete(auditFolder, true);
|
||||||
|
Assert.False(Directory.Exists(auditFolder), "Audit subfolder should be deleted");
|
||||||
|
|
||||||
|
// Write another log, which should trigger folder recreation
|
||||||
|
logger.LogInformation("Second log message after folder deletion");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(Directory.Exists(auditFolder), "Audit subfolder should be recreated");
|
||||||
|
var logFile = Directory.GetFiles(auditFolder, "log_*.txt").FirstOrDefault();
|
||||||
|
Assert.NotNull(logFile);
|
||||||
|
var logContent = File.ReadAllText(logFile);
|
||||||
|
Assert.Contains("Second log message after folder deletion", logContent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -197,4 +197,42 @@ public class JsonFileLoggerTests {
|
|||||||
var logContent = File.ReadAllText(logFile);
|
var logContent = File.ReadAllText(logFile);
|
||||||
Assert.Contains("Order service JSON log message", logContent);
|
Assert.Contains("Order service JSON log message", logContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldRecreateJsonLogFolderIfDeleted() {
|
||||||
|
// Arrange
|
||||||
|
var serviceCollection = new ServiceCollection();
|
||||||
|
serviceCollection.AddSingleton<IHostEnvironment>(sp =>
|
||||||
|
new TestHostEnvironment {
|
||||||
|
EnvironmentName = Environments.Development,
|
||||||
|
ApplicationName = "TestApp",
|
||||||
|
ContentRootPath = Directory.GetCurrentDirectory()
|
||||||
|
});
|
||||||
|
|
||||||
|
serviceCollection.AddLogging(builder => builder.AddJsonFileLogger(_testFolderPath, TimeSpan.FromDays(7)));
|
||||||
|
|
||||||
|
var provider = serviceCollection.BuildServiceProvider();
|
||||||
|
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
|
||||||
|
|
||||||
|
// Act - Create logger and write a log (folder is created)
|
||||||
|
var logger = loggerFactory.CreateLogger(LoggerPrefix.Folder.WithValue("Audit"));
|
||||||
|
logger.LogInformation("First JSON log message");
|
||||||
|
|
||||||
|
var auditFolder = Path.Combine(_testFolderPath, "Audit");
|
||||||
|
Assert.True(Directory.Exists(auditFolder), "Audit subfolder should be created");
|
||||||
|
|
||||||
|
// Delete the folder
|
||||||
|
Directory.Delete(auditFolder, true);
|
||||||
|
Assert.False(Directory.Exists(auditFolder), "Audit subfolder should be deleted");
|
||||||
|
|
||||||
|
// Write another log, which should trigger folder recreation
|
||||||
|
logger.LogInformation("Second JSON log message after folder deletion");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(Directory.Exists(auditFolder), "Audit subfolder should be recreated");
|
||||||
|
var logFile = Directory.GetFiles(auditFolder, "log_*.json").FirstOrDefault();
|
||||||
|
Assert.NotNull(logFile);
|
||||||
|
var logContent = File.ReadAllText(logFile);
|
||||||
|
Assert.Contains("Second JSON log message after folder deletion", logContent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -45,6 +45,18 @@ public abstract class BaseFileLogger : ILogger, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected Task AppendToLogFileAsync(string logFileName, string content) {
|
protected Task AppendToLogFileAsync(string logFileName, string content) {
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(logFileName))
|
||||||
|
throw new ArgumentException("Log file name must not be null or empty.", nameof(logFileName));
|
||||||
|
|
||||||
|
var directory = Path.GetDirectoryName(logFileName);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(directory))
|
||||||
|
throw new ArgumentException("Log file path must include a directory.", nameof(logFileName));
|
||||||
|
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
bool mutexAcquired = false;
|
bool mutexAcquired = false;
|
||||||
try {
|
try {
|
||||||
mutexAcquired = _fileMutex.WaitOne(10000);
|
mutexAcquired = _fileMutex.WaitOne(10000);
|
||||||
|
|||||||
@ -20,11 +20,13 @@ public class FileLoggerProvider : ILoggerProvider {
|
|||||||
private string ResolveFolderPath(string categoryName) {
|
private string ResolveFolderPath(string categoryName) {
|
||||||
var (prefix, value) = LoggerPrefix.Parse(categoryName);
|
var (prefix, value) = LoggerPrefix.Parse(categoryName);
|
||||||
|
|
||||||
|
var newFolderPath = _folderPath;
|
||||||
|
|
||||||
if (prefix == LoggerPrefix.Folder && !string.IsNullOrWhiteSpace(value)) {
|
if (prefix == LoggerPrefix.Folder && !string.IsNullOrWhiteSpace(value)) {
|
||||||
return Path.Combine(_folderPath, SanitizeForPath(value));
|
newFolderPath = Path.Combine(newFolderPath, SanitizeForPath(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return _folderPath;
|
return newFolderPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SanitizeForPath(string input) {
|
private static string SanitizeForPath(string input) {
|
||||||
|
|||||||
@ -20,11 +20,13 @@ public class JsonFileLoggerProvider : ILoggerProvider {
|
|||||||
private string ResolveFolderPath(string categoryName) {
|
private string ResolveFolderPath(string categoryName) {
|
||||||
var (prefix, value) = LoggerPrefix.Parse(categoryName);
|
var (prefix, value) = LoggerPrefix.Parse(categoryName);
|
||||||
|
|
||||||
|
var newFolderPath = _folderPath;
|
||||||
|
|
||||||
if (prefix == LoggerPrefix.Folder && !string.IsNullOrWhiteSpace(value)) {
|
if (prefix == LoggerPrefix.Folder && !string.IsNullOrWhiteSpace(value)) {
|
||||||
return Path.Combine(_folderPath, SanitizeForPath(value));
|
newFolderPath = Path.Combine(newFolderPath, SanitizeForPath(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return _folderPath;
|
return newFolderPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SanitizeForPath(string input) {
|
private static string SanitizeForPath(string input) {
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<!-- NuGet package metadata -->
|
<!-- NuGet package metadata -->
|
||||||
<PackageId>MaksIT.Core</PackageId>
|
<PackageId>MaksIT.Core</PackageId>
|
||||||
<Version>1.6.1</Version>
|
<Version>1.6.2</Version>
|
||||||
<Authors>Maksym Sadovnychyy</Authors>
|
<Authors>Maksym Sadovnychyy</Authors>
|
||||||
<Company>MAKS-IT</Company>
|
<Company>MAKS-IT</Company>
|
||||||
<Product>MaksIT.Core</Product>
|
<Product>MaksIT.Core</Product>
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
@echo off
|
|
||||||
|
|
||||||
REM Change directory to the location of the script
|
|
||||||
cd /d %~dp0
|
|
||||||
|
|
||||||
REM Run AI changelog generator (dry-run mode with debug output)
|
|
||||||
powershell -ExecutionPolicy Bypass -File "%~dp0Generate-Changelog.ps1"
|
|
||||||
|
|
||||||
pause
|
|
||||||
@ -1,452 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
AI-assisted changelog generation and license year update.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Generates changelog entries from uncommitted changes using a 3-pass LLM pipeline:
|
|
||||||
1. Analyze: Convert changes to changelog items
|
|
||||||
2. Consolidate: Merge similar items, remove duplicates
|
|
||||||
3. Format: Structure as Keep a Changelog format
|
|
||||||
|
|
||||||
Also updates LICENSE.md copyright year if needed.
|
|
||||||
Optional RAG pre-processing clusters related changes using embeddings.
|
|
||||||
All configuration is in changelogsettings.json.
|
|
||||||
|
|
||||||
.PARAMETER DryRun
|
|
||||||
Show what would be generated without making changes.
|
|
||||||
Enables debug output showing intermediate LLM results.
|
|
||||||
Does not modify CHANGELOG.md or LICENSE.md.
|
|
||||||
|
|
||||||
.USAGE
|
|
||||||
Generate changelog and update license:
|
|
||||||
.\Generate-Changelog.ps1
|
|
||||||
|
|
||||||
Dry run (preview without changes):
|
|
||||||
.\Generate-Changelog.ps1 -DryRun
|
|
||||||
|
|
||||||
.NOTES
|
|
||||||
Requires:
|
|
||||||
- Ollama running locally (configured in changelogsettings.json)
|
|
||||||
- OllamaClient.psm1 and BuildUtils.psm1 modules
|
|
||||||
|
|
||||||
Configuration (changelogsettings.json):
|
|
||||||
- csprojPath: Path to .csproj file for version
|
|
||||||
- outputFile: Path to CHANGELOG.md
|
|
||||||
- licensePath: Path to LICENSE.md
|
|
||||||
- debug: Enable debug output
|
|
||||||
- models: LLM models for each pass
|
|
||||||
- prompts: Prompt templates
|
|
||||||
#>
|
|
||||||
|
|
||||||
param(
|
|
||||||
[switch]$DryRun
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# PATH CONFIGURATION
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
|
||||||
if (-not $repoRoot) {
|
|
||||||
# Fallback if not in git repo - go up two levels (scripts -> src -> repo root)
|
|
||||||
$repoRoot = Split-Path -Parent (Split-Path -Parent $scriptDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
$repoRoot = $repoRoot.Trim()
|
|
||||||
|
|
||||||
# Solution directory is one level up from scripts
|
|
||||||
$solutionDir = Split-Path -Parent $scriptDir
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# LOAD SETTINGS
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
$settingsPath = Join-Path $scriptDir "changelogsettings.json"
|
|
||||||
if (-not (Test-Path $settingsPath)) {
|
|
||||||
Write-Error "Settings file not found: $settingsPath"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
|
|
||||||
Write-Host "Loaded settings from changelogsettings.json" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# Resolve paths relative to script location
|
|
||||||
$CsprojPath = if ($settings.changelog.csprojPath) {
|
|
||||||
[System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.changelog.csprojPath))
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Join-Path $solutionDir "MaksIT.Core\MaksIT.Core.csproj"
|
|
||||||
}
|
|
||||||
|
|
||||||
$OutputFile = if ($settings.changelog.outputFile) {
|
|
||||||
[System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.changelog.outputFile))
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$null
|
|
||||||
}
|
|
||||||
|
|
||||||
$LicensePath = if ($settings.changelog.licensePath) {
|
|
||||||
[System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.changelog.licensePath))
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$null
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# LICENSE YEAR UPDATE
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
if ($LicensePath -and (Test-Path $LicensePath)) {
|
|
||||||
Write-Host "Checking LICENSE.md copyright year..." -ForegroundColor Gray
|
|
||||||
$currentYear = (Get-Date).Year
|
|
||||||
$licenseContent = Get-Content $LicensePath -Raw
|
|
||||||
|
|
||||||
# Match pattern: "Copyright (c) YYYY - YYYY" and update end year
|
|
||||||
$licensePattern = "(Copyright \(c\) \d{4}\s*-\s*)(\d{4})"
|
|
||||||
|
|
||||||
if ($licenseContent -match $licensePattern) {
|
|
||||||
$existingEndYear = [int]$Matches[2]
|
|
||||||
|
|
||||||
if ($existingEndYear -lt $currentYear) {
|
|
||||||
if ($DryRun) {
|
|
||||||
Write-Host "[DryRun] LICENSE.md needs update: $existingEndYear -> $currentYear" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Updating LICENSE.md copyright year: $existingEndYear -> $currentYear" -ForegroundColor Cyan
|
|
||||||
$updatedContent = $licenseContent -replace $licensePattern, "`${1}$currentYear"
|
|
||||||
Set-Content -Path $LicensePath -Value $updatedContent -NoNewline
|
|
||||||
Write-Host "LICENSE.md updated." -ForegroundColor Green
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "LICENSE.md copyright year is current ($existingEndYear)." -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# IMPORT MODULES
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
# Import build utilities
|
|
||||||
$buildUtilsPath = Join-Path $scriptDir "BuildUtils.psm1"
|
|
||||||
if (Test-Path $buildUtilsPath) {
|
|
||||||
Import-Module $buildUtilsPath -Force
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Error "BuildUtils.psm1 not found: $buildUtilsPath"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Import Ollama client
|
|
||||||
$ollamaModulePath = Join-Path $scriptDir "OllamaClient.psm1"
|
|
||||||
if (-not $settings.ollama.enabled) {
|
|
||||||
Write-Error "Ollama is disabled in changelogsettings.json"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $ollamaModulePath)) {
|
|
||||||
Write-Error "OllamaClient.psm1 not found: $ollamaModulePath"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Import-Module $ollamaModulePath -Force
|
|
||||||
Set-OllamaConfig -ApiUrl $settings.ollama.apiUrl `
|
|
||||||
-DefaultContextWindow $settings.ollama.defaultContextWindow `
|
|
||||||
-DefaultTimeout $settings.ollama.defaultTimeout
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# CHANGELOG CONFIGURATION
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
$clSettings = $settings.changelog
|
|
||||||
$changelogConfig = @{
|
|
||||||
Debug = if ($DryRun) { $true } else { $clSettings.debug }
|
|
||||||
EnableRAG = $clSettings.enableRAG
|
|
||||||
SimilarityThreshold = $clSettings.similarityThreshold
|
|
||||||
FileExtension = $clSettings.fileExtension
|
|
||||||
ExcludePatterns = if ($clSettings.excludePatterns) { @($clSettings.excludePatterns) } else { @() }
|
|
||||||
Models = @{
|
|
||||||
Analyze = @{
|
|
||||||
Name = $clSettings.models.analyze.name
|
|
||||||
Context = $clSettings.models.analyze.context
|
|
||||||
MaxTokens = if ($null -ne $clSettings.models.analyze.maxTokens) { $clSettings.models.analyze.maxTokens } else { 0 }
|
|
||||||
}
|
|
||||||
Reason = @{
|
|
||||||
Name = $clSettings.models.reason.name
|
|
||||||
Context = $clSettings.models.reason.context
|
|
||||||
MaxTokens = if ($null -ne $clSettings.models.reason.maxTokens) { $clSettings.models.reason.maxTokens } else { 0 }
|
|
||||||
Temperature = if ($clSettings.models.reason.temperature) { $clSettings.models.reason.temperature } else { 0.1 }
|
|
||||||
}
|
|
||||||
Write = @{
|
|
||||||
Name = $clSettings.models.write.name
|
|
||||||
Context = $clSettings.models.write.context
|
|
||||||
MaxTokens = if ($null -ne $clSettings.models.write.maxTokens) { $clSettings.models.write.maxTokens } else { 0 }
|
|
||||||
}
|
|
||||||
Embed = @{ Name = $clSettings.models.embed.name }
|
|
||||||
}
|
|
||||||
Prompts = @{
|
|
||||||
Analyze = if ($clSettings.prompts.analyze) {
|
|
||||||
if ($clSettings.prompts.analyze -is [array]) { $clSettings.prompts.analyze -join "`n" } else { $clSettings.prompts.analyze }
|
|
||||||
} else { "Convert changes to changelog: {{changes}}" }
|
|
||||||
Reason = if ($clSettings.prompts.reason) {
|
|
||||||
if ($clSettings.prompts.reason -is [array]) { $clSettings.prompts.reason -join "`n" } else { $clSettings.prompts.reason }
|
|
||||||
} else { "Consolidate: {{input}}" }
|
|
||||||
Format = if ($clSettings.prompts.format) {
|
|
||||||
if ($clSettings.prompts.format -is [array]) { $clSettings.prompts.format -join "`n" } else { $clSettings.prompts.format }
|
|
||||||
} else { "Format as changelog: {{items}}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# AI CHANGELOG GENERATION FUNCTION
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
function Get-AIChangelogSuggestion {
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$Changes,
|
|
||||||
[Parameter(Mandatory)][string]$Version
|
|
||||||
)
|
|
||||||
|
|
||||||
$cfg = $script:changelogConfig
|
|
||||||
$debug = $cfg.Debug
|
|
||||||
|
|
||||||
# === RAG PRE-PROCESSING ===
|
|
||||||
$processedChanges = $Changes
|
|
||||||
|
|
||||||
if ($cfg.EnableRAG) {
|
|
||||||
Write-Host " RAG Pre-processing ($($cfg.Models.Embed.Name))..." -ForegroundColor Cyan
|
|
||||||
$changeArray = $Changes -split "`n" | Where-Object { $_.Trim() -ne "" }
|
|
||||||
|
|
||||||
if ($changeArray.Length -gt 3) {
|
|
||||||
Write-Host " RAG: Embedding $($changeArray.Length) changes..." -ForegroundColor Gray
|
|
||||||
$clusters = Group-TextsByEmbedding -Model $cfg.Models.Embed.Name -Texts $changeArray -SimilarityThreshold $cfg.SimilarityThreshold
|
|
||||||
Write-Host " RAG: Reduced to $($clusters.Length) groups" -ForegroundColor Green
|
|
||||||
|
|
||||||
# Format clusters
|
|
||||||
$grouped = @()
|
|
||||||
foreach ($cluster in $clusters) {
|
|
||||||
if ($cluster.Length -eq 1) {
|
|
||||||
$grouped += $cluster[0]
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$grouped += "[RELATED CHANGES]`n" + ($cluster -join "`n") + "`n[/RELATED CHANGES]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$processedChanges = $grouped -join "`n"
|
|
||||||
|
|
||||||
if ($debug) {
|
|
||||||
Write-Host "`n [DEBUG] RAG grouped changes:" -ForegroundColor Magenta
|
|
||||||
Write-Host $processedChanges -ForegroundColor DarkGray
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# === PASS 1: Analyze changes ===
|
|
||||||
$m1 = $cfg.Models.Analyze
|
|
||||||
Write-Host " Pass 1/3: Analyzing ($($m1.Name), ctx:$($m1.Context))..." -ForegroundColor Gray
|
|
||||||
|
|
||||||
$prompt1 = $cfg.Prompts.Analyze -replace '\{\{changes\}\}', $processedChanges
|
|
||||||
$pass1 = Invoke-OllamaPrompt -Model $m1.Name -ContextWindow $m1.Context -MaxTokens $m1.MaxTokens -Prompt $prompt1
|
|
||||||
|
|
||||||
if (-not $pass1) { return $null }
|
|
||||||
if ($debug) { Write-Host "`n [DEBUG] Pass 1 output:" -ForegroundColor Magenta; Write-Host $pass1 -ForegroundColor DarkGray; Write-Host "" }
|
|
||||||
|
|
||||||
# === PASS 2: Consolidate ===
|
|
||||||
$m2 = $cfg.Models.Reason
|
|
||||||
Write-Host " Pass 2/3: Consolidating ($($m2.Name), ctx:$($m2.Context))..." -ForegroundColor Gray
|
|
||||||
|
|
||||||
$prompt2 = $cfg.Prompts.Reason -replace '\{\{input\}\}', $pass1
|
|
||||||
$pass2 = Invoke-OllamaPrompt -Model $m2.Name -ContextWindow $m2.Context -MaxTokens $m2.MaxTokens -Temperature $m2.Temperature -Prompt $prompt2
|
|
||||||
|
|
||||||
if (-not $pass2) { return $pass1 }
|
|
||||||
if ($pass2 -match "</think>") { $pass2 = ($pass2 -split "</think>")[-1].Trim() }
|
|
||||||
if ($debug) { Write-Host "`n [DEBUG] Pass 2 output:" -ForegroundColor Magenta; Write-Host $pass2 -ForegroundColor DarkGray; Write-Host "" }
|
|
||||||
|
|
||||||
# === PASS 3: Format ===
|
|
||||||
$m3 = $cfg.Models.Write
|
|
||||||
Write-Host " Pass 3/3: Formatting ($($m3.Name), ctx:$($m3.Context))..." -ForegroundColor Gray
|
|
||||||
|
|
||||||
$prompt3 = $cfg.Prompts.Format -replace '\{\{items\}\}', $pass2
|
|
||||||
$pass3 = Invoke-OllamaPrompt -Model $m3.Name -ContextWindow $m3.Context -MaxTokens $m3.MaxTokens -Prompt $prompt3
|
|
||||||
|
|
||||||
if (-not $pass3) { return $pass2 }
|
|
||||||
if ($debug) { Write-Host "`n [DEBUG] Pass 3 output:" -ForegroundColor Magenta; Write-Host $pass3 -ForegroundColor DarkGray; Write-Host "" }
|
|
||||||
|
|
||||||
# Clean up preamble
|
|
||||||
if ($pass3 -match "(### Added|### Changed|### Fixed|### Removed)") {
|
|
||||||
$pass3 = $pass3.Substring($pass3.IndexOf($Matches[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
# Clean up headers - remove any extra text after "### Added" etc.
|
|
||||||
$pass3 = $pass3 -replace '(### Added)[^\n]*', '### Added'
|
|
||||||
$pass3 = $pass3 -replace '(### Changed)[^\n]*', '### Changed'
|
|
||||||
$pass3 = $pass3 -replace '(### Fixed)[^\n]*', '### Fixed'
|
|
||||||
$pass3 = $pass3 -replace '(### Removed)[^\n]*', '### Removed'
|
|
||||||
|
|
||||||
# Clean up formatting: remove extra blank lines, normalize line endings
|
|
||||||
$pass3 = $pass3 -replace "`r`n", "`n" # Normalize to LF
|
|
||||||
$pass3 = $pass3 -replace "(\n\s*){3,}", "`n`n" # Max 1 blank line
|
|
||||||
$pass3 = $pass3 -replace "- (.+)\n\n- ", "- `$1`n- " # No blank between items
|
|
||||||
$pass3 = $pass3 -replace "\n{2,}(### )", "`n`n`$1" # One blank before headers
|
|
||||||
|
|
||||||
# Remove empty sections (e.g., "### Fixed\n- (No items)" or "### Removed\n\n###")
|
|
||||||
$pass3 = $pass3 -replace "### \w+\s*\n-\s*\(No items\)\s*\n?", ""
|
|
||||||
$pass3 = $pass3 -replace "### \w+\s*\n\s*\n(?=###|$)", ""
|
|
||||||
$pass3 = $pass3.Trim()
|
|
||||||
|
|
||||||
return $pass3
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# MAIN EXECUTION
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "==================================================" -ForegroundColor Cyan
|
|
||||||
Write-Host "AI CHANGELOG GENERATOR" -ForegroundColor Cyan
|
|
||||||
Write-Host "==================================================" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Check Ollama availability
|
|
||||||
if (-not (Test-OllamaAvailable)) {
|
|
||||||
Write-Error "Ollama is not available. Start Ollama and try again."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Ollama connected: $($settings.ollama.apiUrl)" -ForegroundColor Green
|
|
||||||
Write-Host "Models: $($changelogConfig.Models.Analyze.Name) | $($changelogConfig.Models.Reason.Name) | $($changelogConfig.Models.Embed.Name)" -ForegroundColor Gray
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Get version from csproj
|
|
||||||
if (-not (Test-Path $CsprojPath)) {
|
|
||||||
Write-Error "Csproj file not found: $CsprojPath"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[xml]$csproj = Get-Content $CsprojPath
|
|
||||||
$Version = $csproj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1
|
|
||||||
|
|
||||||
Write-Host "Version: $Version" -ForegroundColor White
|
|
||||||
|
|
||||||
# Filter function for excluding test files
|
|
||||||
$excludePatterns = $changelogConfig.ExcludePatterns
|
|
||||||
function Test-Excluded {
|
|
||||||
param([string]$Item)
|
|
||||||
foreach ($pattern in $excludePatterns) {
|
|
||||||
if ($Item -match [regex]::Escape($pattern)) { return $true }
|
|
||||||
}
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get committed changes for this version (analyzed diffs)
|
|
||||||
$committedChanges = Get-CommitChangesAnalysis -Version $Version -CsprojPath $CsprojPath -FileFilter $changelogConfig.FileExtension
|
|
||||||
$filteredCommitted = $committedChanges | Where-Object { -not (Test-Excluded $_) }
|
|
||||||
|
|
||||||
# Get uncommitted changes (staged, modified, new, deleted)
|
|
||||||
$uncommitted = Get-UncommittedChanges -FileFilter $changelogConfig.FileExtension
|
|
||||||
$filteredUncommitted = $uncommitted.Summary | Where-Object { -not (Test-Excluded $_) }
|
|
||||||
|
|
||||||
# Combine all changes
|
|
||||||
$allChanges = @()
|
|
||||||
if ($filteredCommitted.Count -gt 0) { $allChanges += $filteredCommitted }
|
|
||||||
if ($filteredUncommitted.Count -gt 0) { $allChanges += $filteredUncommitted }
|
|
||||||
|
|
||||||
if ($allChanges.Count -eq 0) {
|
|
||||||
Write-Host "No changes found for version $Version (excluding tests)" -ForegroundColor Yellow
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
$changeLog = $allChanges -join "`n"
|
|
||||||
|
|
||||||
Write-Host "Found $($filteredCommitted.Count) committed changes" -ForegroundColor Gray
|
|
||||||
Write-Host "Found $($filteredUncommitted.Count) uncommitted changes" -ForegroundColor Gray
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Generate changelog from uncommitted changes
|
|
||||||
$suggestion = Get-AIChangelogSuggestion -Changes $changeLog -Version $Version
|
|
||||||
|
|
||||||
if ($suggestion) {
|
|
||||||
$fullEntry = "## v$Version`n`n$suggestion"
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "==========================================" -ForegroundColor Green
|
|
||||||
Write-Host "AI SUGGESTED CHANGELOG ENTRY" -ForegroundColor Green
|
|
||||||
Write-Host "==========================================" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host $fullEntry -ForegroundColor White
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "==========================================" -ForegroundColor Green
|
|
||||||
|
|
||||||
# Update changelog file if specified and not in DryRun mode
|
|
||||||
if ($OutputFile -and -not $DryRun) {
|
|
||||||
if (Test-Path $OutputFile) {
|
|
||||||
# Read existing content
|
|
||||||
$existingContent = Get-Content $OutputFile -Raw
|
|
||||||
|
|
||||||
# Check if this version already exists
|
|
||||||
if ($existingContent -match "## v$Version\b") {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "WARNING: Version $Version already exists in $OutputFile" -ForegroundColor Yellow
|
|
||||||
Write-Host "Skipping file update. Review and update manually if needed." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
# Find insertion point (after header, before first version entry)
|
|
||||||
# Header typically ends before first "## v" line
|
|
||||||
if ($existingContent -match '(?s)(^.*?)(\r?\n)(## v)') {
|
|
||||||
$header = $Matches[1]
|
|
||||||
$newline = $Matches[2]
|
|
||||||
$rest = $existingContent.Substring($header.Length + $newline.Length)
|
|
||||||
$newContent = $header + "`n`n" + $fullEntry + "`n`n" + $rest
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
# No existing version entries - append after content
|
|
||||||
$newContent = $existingContent.TrimEnd() + "`n`n" + $fullEntry + "`n"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Normalize multiple blank lines to max 2
|
|
||||||
$newContent = $newContent -replace "(\r?\n){3,}", "`n`n"
|
|
||||||
|
|
||||||
$newContent | Out-File -FilePath $OutputFile -Encoding utf8 -NoNewline
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Updated: $OutputFile" -ForegroundColor Cyan
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
# Create new file with header
|
|
||||||
$newContent = @"
|
|
||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
$fullEntry
|
|
||||||
"@
|
|
||||||
$newContent | Out-File -FilePath $OutputFile -Encoding utf8
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Created: $OutputFile" -ForegroundColor Cyan
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elseif ($OutputFile -and $DryRun) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "[DryRun] Would update: $OutputFile" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
if ($DryRun) {
|
|
||||||
Write-Host "DryRun complete. No files were modified." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Review the changelog entry, then commit." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Error "AI changelog generation failed"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
@ -1,572 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Generic Ollama API client module for PowerShell.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Provides a simple interface to interact with Ollama's local LLM API:
|
|
||||||
- Text generation (chat/completion)
|
|
||||||
- Embeddings generation
|
|
||||||
- Model management
|
|
||||||
- RAG utilities (cosine similarity, clustering)
|
|
||||||
|
|
||||||
.REQUIREMENTS
|
|
||||||
- Ollama running locally (default: http://localhost:11434)
|
|
||||||
|
|
||||||
.USAGE
|
|
||||||
Import-Module .\OllamaClient.psm1
|
|
||||||
|
|
||||||
# Configure
|
|
||||||
Set-OllamaConfig -ApiUrl "http://localhost:11434"
|
|
||||||
|
|
||||||
# Check availability
|
|
||||||
if (Test-OllamaAvailable) {
|
|
||||||
# Generate text
|
|
||||||
$response = Invoke-OllamaPrompt -Model "llama3.1:8b" -Prompt "Hello!"
|
|
||||||
|
|
||||||
# Get embeddings
|
|
||||||
$embedding = Get-OllamaEmbedding -Model "nomic-embed-text" -Text "Sample text"
|
|
||||||
}
|
|
||||||
#>
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# MODULE CONFIGURATION
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
$script:OllamaConfig = @{
|
|
||||||
ApiUrl = "http://localhost:11434"
|
|
||||||
DefaultTimeout = 180
|
|
||||||
DefaultTemperature = 0.2
|
|
||||||
DefaultMaxTokens = 0
|
|
||||||
DefaultContextWindow = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# CONFIGURATION FUNCTIONS
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
function Set-OllamaConfig {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Configure Ollama client settings.
|
|
||||||
.PARAMETER ApiUrl
|
|
||||||
Ollama API endpoint URL (default: http://localhost:11434).
|
|
||||||
.PARAMETER DefaultTimeout
|
|
||||||
Default timeout in seconds for API calls.
|
|
||||||
.PARAMETER DefaultTemperature
|
|
||||||
Default temperature for text generation (0.0-1.0).
|
|
||||||
.PARAMETER DefaultMaxTokens
|
|
||||||
Default maximum tokens to generate.
|
|
||||||
.PARAMETER DefaultContextWindow
|
|
||||||
Default context window size (num_ctx).
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[string]$ApiUrl,
|
|
||||||
[int]$DefaultTimeout,
|
|
||||||
[double]$DefaultTemperature,
|
|
||||||
[int]$DefaultMaxTokens,
|
|
||||||
[int]$DefaultContextWindow
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($ApiUrl) {
|
|
||||||
$script:OllamaConfig.ApiUrl = $ApiUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($PSBoundParameters.ContainsKey('DefaultTimeout')) {
|
|
||||||
$script:OllamaConfig.DefaultTimeout = $DefaultTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($PSBoundParameters.ContainsKey('DefaultTemperature')) {
|
|
||||||
$script:OllamaConfig.DefaultTemperature = $DefaultTemperature
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($PSBoundParameters.ContainsKey('DefaultMaxTokens')) {
|
|
||||||
$script:OllamaConfig.DefaultMaxTokens = $DefaultMaxTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($PSBoundParameters.ContainsKey('DefaultContextWindow')) {
|
|
||||||
$script:OllamaConfig.DefaultContextWindow = $DefaultContextWindow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-OllamaConfig {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Get current Ollama client configuration.
|
|
||||||
#>
|
|
||||||
return $script:OllamaConfig.Clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# CONNECTION & STATUS
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
function Test-OllamaAvailable {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Check if Ollama API is available and responding.
|
|
||||||
.OUTPUTS
|
|
||||||
Boolean indicating if Ollama is available.
|
|
||||||
#>
|
|
||||||
try {
|
|
||||||
$null = Invoke-RestMethod -Uri "$($script:OllamaConfig.ApiUrl)/api/tags" -TimeoutSec 5 -ErrorAction Stop
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-OllamaModels {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Get list of available models from Ollama.
|
|
||||||
.OUTPUTS
|
|
||||||
Array of model objects with name, size, and other properties.
|
|
||||||
#>
|
|
||||||
try {
|
|
||||||
$response = Invoke-RestMethod -Uri "$($script:OllamaConfig.ApiUrl)/api/tags" -TimeoutSec 10 -ErrorAction Stop
|
|
||||||
return $response.models
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "Failed to get Ollama models: $_"
|
|
||||||
return @()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Test-OllamaModel {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Check if a specific model is available in Ollama.
|
|
||||||
.PARAMETER Model
|
|
||||||
Model name to check.
|
|
||||||
#>
|
|
||||||
param([Parameter(Mandatory)][string]$Model)
|
|
||||||
|
|
||||||
$models = Get-OllamaModels
|
|
||||||
return ($models | Where-Object { $_.name -eq $Model -or $_.name -like "${Model}:*" }) -ne $null
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# TEXT GENERATION
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
function Invoke-OllamaPrompt {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Send a prompt to an Ollama model and get a response.
|
|
||||||
.PARAMETER Model
|
|
||||||
Model name (e.g., "llama3.1:8b", "qwen2.5-coder:7b").
|
|
||||||
.PARAMETER Prompt
|
|
||||||
The prompt text to send.
|
|
||||||
.PARAMETER ContextWindow
|
|
||||||
Context window size (num_ctx). Uses default if not specified.
|
|
||||||
.PARAMETER MaxTokens
|
|
||||||
Maximum tokens to generate (num_predict). Uses default if not specified.
|
|
||||||
.PARAMETER Temperature
|
|
||||||
Temperature for generation (0.0-1.0). Uses default if not specified.
|
|
||||||
.PARAMETER Timeout
|
|
||||||
Timeout in seconds. Uses default if not specified.
|
|
||||||
.PARAMETER System
|
|
||||||
Optional system prompt.
|
|
||||||
.OUTPUTS
|
|
||||||
Generated text response or $null if failed.
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$Model,
|
|
||||||
[Parameter(Mandatory)][string]$Prompt,
|
|
||||||
[int]$ContextWindow,
|
|
||||||
[int]$MaxTokens,
|
|
||||||
[double]$Temperature,
|
|
||||||
[int]$Timeout,
|
|
||||||
[string]$System
|
|
||||||
)
|
|
||||||
|
|
||||||
$config = $script:OllamaConfig
|
|
||||||
|
|
||||||
# Use defaults if not specified
|
|
||||||
if (-not $PSBoundParameters.ContainsKey('MaxTokens')) { $MaxTokens = $config.DefaultMaxTokens }
|
|
||||||
if (-not $PSBoundParameters.ContainsKey('Temperature')) { $Temperature = $config.DefaultTemperature }
|
|
||||||
if (-not $PSBoundParameters.ContainsKey('Timeout')) { $Timeout = $config.DefaultTimeout }
|
|
||||||
|
|
||||||
$options = @{
|
|
||||||
temperature = $Temperature
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only set num_predict if MaxTokens > 0 (0 = unlimited/model default)
|
|
||||||
if ($MaxTokens -and $MaxTokens -gt 0) {
|
|
||||||
$options.num_predict = $MaxTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only set context window if explicitly provided (let model use its default otherwise)
|
|
||||||
if ($ContextWindow -and $ContextWindow -gt 0) {
|
|
||||||
$options.num_ctx = $ContextWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = @{
|
|
||||||
model = $Model
|
|
||||||
prompt = $Prompt
|
|
||||||
stream = $false
|
|
||||||
options = $options
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($System) {
|
|
||||||
$body.system = $System
|
|
||||||
}
|
|
||||||
|
|
||||||
$jsonBody = $body | ConvertTo-Json -Depth 3
|
|
||||||
|
|
||||||
# TimeoutSec 0 = infinite wait
|
|
||||||
$restParams = @{
|
|
||||||
Uri = "$($config.ApiUrl)/api/generate"
|
|
||||||
Method = "Post"
|
|
||||||
Body = $jsonBody
|
|
||||||
ContentType = "application/json"
|
|
||||||
}
|
|
||||||
if ($Timeout -gt 0) { $restParams.TimeoutSec = $Timeout }
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = Invoke-RestMethod @restParams
|
|
||||||
return $response.response.Trim()
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "Ollama prompt failed: $_"
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Invoke-OllamaChat {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Send a chat conversation to an Ollama model.
|
|
||||||
.PARAMETER Model
|
|
||||||
Model name.
|
|
||||||
.PARAMETER Messages
|
|
||||||
Array of message objects with 'role' and 'content' properties.
|
|
||||||
Roles: "system", "user", "assistant"
|
|
||||||
.PARAMETER ContextWindow
|
|
||||||
Context window size.
|
|
||||||
.PARAMETER MaxTokens
|
|
||||||
Maximum tokens to generate.
|
|
||||||
.PARAMETER Temperature
|
|
||||||
Temperature for generation.
|
|
||||||
.OUTPUTS
|
|
||||||
Generated response text or $null if failed.
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$Model,
|
|
||||||
[Parameter(Mandatory)][array]$Messages,
|
|
||||||
[int]$ContextWindow,
|
|
||||||
[int]$MaxTokens,
|
|
||||||
[double]$Temperature,
|
|
||||||
[int]$Timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
$config = $script:OllamaConfig
|
|
||||||
|
|
||||||
if (-not $PSBoundParameters.ContainsKey('MaxTokens')) { $MaxTokens = $config.DefaultMaxTokens }
|
|
||||||
if (-not $PSBoundParameters.ContainsKey('Temperature')) { $Temperature = $config.DefaultTemperature }
|
|
||||||
if (-not $PSBoundParameters.ContainsKey('Timeout')) { $Timeout = $config.DefaultTimeout }
|
|
||||||
|
|
||||||
$options = @{
|
|
||||||
temperature = $Temperature
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only set num_predict if MaxTokens > 0 (0 = unlimited/model default)
|
|
||||||
if ($MaxTokens -and $MaxTokens -gt 0) {
|
|
||||||
$options.num_predict = $MaxTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only set context window if explicitly provided
|
|
||||||
if ($ContextWindow -and $ContextWindow -gt 0) {
|
|
||||||
$options.num_ctx = $ContextWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = @{
|
|
||||||
model = $Model
|
|
||||||
messages = $Messages
|
|
||||||
stream = $false
|
|
||||||
options = $options
|
|
||||||
}
|
|
||||||
|
|
||||||
$jsonBody = $body | ConvertTo-Json -Depth 4
|
|
||||||
|
|
||||||
# TimeoutSec 0 = infinite wait
|
|
||||||
$restParams = @{
|
|
||||||
Uri = "$($config.ApiUrl)/api/chat"
|
|
||||||
Method = "Post"
|
|
||||||
Body = $jsonBody
|
|
||||||
ContentType = "application/json"
|
|
||||||
}
|
|
||||||
if ($Timeout -gt 0) { $restParams.TimeoutSec = $Timeout }
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = Invoke-RestMethod @restParams
|
|
||||||
return $response.message.content.Trim()
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "Ollama chat failed: $_"
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# EMBEDDINGS
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
function Get-OllamaEmbedding {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Get embedding vector for text using an Ollama embedding model.
|
|
||||||
.PARAMETER Model
|
|
||||||
Embedding model name (e.g., "nomic-embed-text", "mxbai-embed-large").
|
|
||||||
.PARAMETER Text
|
|
||||||
Text to embed.
|
|
||||||
.PARAMETER Timeout
|
|
||||||
Timeout in seconds.
|
|
||||||
.OUTPUTS
|
|
||||||
Array of doubles representing the embedding vector, or $null if failed.
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$Model,
|
|
||||||
[Parameter(Mandatory)][string]$Text,
|
|
||||||
[int]$Timeout = 30
|
|
||||||
)
|
|
||||||
|
|
||||||
$body = @{
|
|
||||||
model = $Model
|
|
||||||
prompt = $Text
|
|
||||||
} | ConvertTo-Json
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = Invoke-RestMethod -Uri "$($script:OllamaConfig.ApiUrl)/api/embeddings" -Method Post -Body $body -ContentType "application/json" -TimeoutSec $Timeout
|
|
||||||
return $response.embedding
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "Ollama embedding failed: $_"
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-OllamaEmbeddings {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Get embeddings for multiple texts (batch).
|
|
||||||
.PARAMETER Model
|
|
||||||
Embedding model name.
|
|
||||||
.PARAMETER Texts
|
|
||||||
Array of texts to embed.
|
|
||||||
.PARAMETER ShowProgress
|
|
||||||
Show progress indicator.
|
|
||||||
.OUTPUTS
|
|
||||||
Array of objects with Text and Embedding properties.
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$Model,
|
|
||||||
[Parameter(Mandatory)][string[]]$Texts,
|
|
||||||
[switch]$ShowProgress
|
|
||||||
)
|
|
||||||
|
|
||||||
$results = @()
|
|
||||||
$total = $Texts.Count
|
|
||||||
$current = 0
|
|
||||||
|
|
||||||
foreach ($text in $Texts) {
|
|
||||||
$current++
|
|
||||||
if ($ShowProgress) {
|
|
||||||
Write-Progress -Activity "Getting embeddings" -Status "$current of $total" -PercentComplete (($current / $total) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
$embedding = Get-OllamaEmbedding -Model $Model -Text $text
|
|
||||||
if ($embedding) {
|
|
||||||
$results += @{
|
|
||||||
Text = $text
|
|
||||||
Embedding = $embedding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ShowProgress) {
|
|
||||||
Write-Progress -Activity "Getting embeddings" -Completed
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# RAG UTILITIES
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
function Get-CosineSimilarity {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Calculate cosine similarity between two embedding vectors.
|
|
||||||
.PARAMETER Vector1
|
|
||||||
First embedding vector.
|
|
||||||
.PARAMETER Vector2
|
|
||||||
Second embedding vector.
|
|
||||||
.OUTPUTS
|
|
||||||
Cosine similarity value between -1 and 1.
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][double[]]$Vector1,
|
|
||||||
[Parameter(Mandatory)][double[]]$Vector2
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($Vector1.Length -ne $Vector2.Length) {
|
|
||||||
Write-Warning "Vector lengths don't match: $($Vector1.Length) vs $($Vector2.Length)"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
$dotProduct = 0.0
|
|
||||||
$norm1 = 0.0
|
|
||||||
$norm2 = 0.0
|
|
||||||
|
|
||||||
for ($i = 0; $i -lt $Vector1.Length; $i++) {
|
|
||||||
$dotProduct += $Vector1[$i] * $Vector2[$i]
|
|
||||||
$norm1 += $Vector1[$i] * $Vector1[$i]
|
|
||||||
$norm2 += $Vector2[$i] * $Vector2[$i]
|
|
||||||
}
|
|
||||||
|
|
||||||
$norm1 = [Math]::Sqrt($norm1)
|
|
||||||
$norm2 = [Math]::Sqrt($norm2)
|
|
||||||
|
|
||||||
if ($norm1 -eq 0 -or $norm2 -eq 0) { return 0 }
|
|
||||||
return $dotProduct / ($norm1 * $norm2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Group-TextsByEmbedding {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Cluster texts by embedding similarity.
|
|
||||||
.PARAMETER Model
|
|
||||||
Embedding model name.
|
|
||||||
.PARAMETER Texts
|
|
||||||
Array of texts to cluster.
|
|
||||||
.PARAMETER SimilarityThreshold
|
|
||||||
Minimum cosine similarity to group texts together (0.0-1.0).
|
|
||||||
.PARAMETER ShowProgress
|
|
||||||
Show progress during embedding.
|
|
||||||
.OUTPUTS
|
|
||||||
Array of clusters (each cluster is an array of texts).
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$Model,
|
|
||||||
[Parameter(Mandatory)][string[]]$Texts,
|
|
||||||
[double]$SimilarityThreshold = 0.65,
|
|
||||||
[switch]$ShowProgress
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($Texts.Length -eq 0) { return @() }
|
|
||||||
if ($Texts.Length -eq 1) { return @(,@($Texts[0])) }
|
|
||||||
|
|
||||||
# Get embeddings
|
|
||||||
$embeddings = Get-OllamaEmbeddings -Model $Model -Texts $Texts -ShowProgress:$ShowProgress
|
|
||||||
|
|
||||||
if ($embeddings.Length -eq 0) {
|
|
||||||
return @($Texts | ForEach-Object { ,@($_) })
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mark all as unclustered
|
|
||||||
$embeddings | ForEach-Object { $_.Clustered = $false }
|
|
||||||
|
|
||||||
# Cluster similar texts
|
|
||||||
$clusters = @()
|
|
||||||
|
|
||||||
for ($i = 0; $i -lt $embeddings.Length; $i++) {
|
|
||||||
if ($embeddings[$i].Clustered) { continue }
|
|
||||||
|
|
||||||
$cluster = @($embeddings[$i].Text)
|
|
||||||
$embeddings[$i].Clustered = $true
|
|
||||||
|
|
||||||
for ($j = $i + 1; $j -lt $embeddings.Length; $j++) {
|
|
||||||
if ($embeddings[$j].Clustered) { continue }
|
|
||||||
|
|
||||||
$similarity = Get-CosineSimilarity -Vector1 $embeddings[$i].Embedding -Vector2 $embeddings[$j].Embedding
|
|
||||||
|
|
||||||
if ($similarity -ge $SimilarityThreshold) {
|
|
||||||
$cluster += $embeddings[$j].Text
|
|
||||||
$embeddings[$j].Clustered = $true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$clusters += ,@($cluster)
|
|
||||||
}
|
|
||||||
|
|
||||||
return $clusters
|
|
||||||
}
|
|
||||||
|
|
||||||
function Find-SimilarTexts {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Find texts most similar to a query using embeddings.
|
|
||||||
.PARAMETER Model
|
|
||||||
Embedding model name.
|
|
||||||
.PARAMETER Query
|
|
||||||
Query text to find similar texts for.
|
|
||||||
.PARAMETER Texts
|
|
||||||
Array of texts to search through.
|
|
||||||
.PARAMETER TopK
|
|
||||||
Number of most similar texts to return.
|
|
||||||
.PARAMETER MinSimilarity
|
|
||||||
Minimum similarity threshold.
|
|
||||||
.OUTPUTS
|
|
||||||
Array of objects with Text and Similarity properties, sorted by similarity.
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$Model,
|
|
||||||
[Parameter(Mandatory)][string]$Query,
|
|
||||||
[Parameter(Mandatory)][string[]]$Texts,
|
|
||||||
[int]$TopK = 5,
|
|
||||||
[double]$MinSimilarity = 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get query embedding
|
|
||||||
$queryEmbedding = Get-OllamaEmbedding -Model $Model -Text $Query
|
|
||||||
if (-not $queryEmbedding) { return @() }
|
|
||||||
|
|
||||||
# Get text embeddings and calculate similarities
|
|
||||||
$results = @()
|
|
||||||
foreach ($text in $Texts) {
|
|
||||||
$textEmbedding = Get-OllamaEmbedding -Model $Model -Text $text
|
|
||||||
if ($textEmbedding) {
|
|
||||||
$similarity = Get-CosineSimilarity -Vector1 $queryEmbedding -Vector2 $textEmbedding
|
|
||||||
if ($similarity -ge $MinSimilarity) {
|
|
||||||
$results += @{
|
|
||||||
Text = $text
|
|
||||||
Similarity = $similarity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Sort by similarity and return top K
|
|
||||||
return $results | Sort-Object -Property Similarity -Descending | Select-Object -First $TopK
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# MODULE EXPORTS
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
Export-ModuleMember -Function @(
|
|
||||||
# Configuration
|
|
||||||
'Set-OllamaConfig'
|
|
||||||
'Get-OllamaConfig'
|
|
||||||
|
|
||||||
# Connection & Status
|
|
||||||
'Test-OllamaAvailable'
|
|
||||||
'Get-OllamaModels'
|
|
||||||
'Test-OllamaModel'
|
|
||||||
|
|
||||||
# Text Generation
|
|
||||||
'Invoke-OllamaPrompt'
|
|
||||||
'Invoke-OllamaChat'
|
|
||||||
|
|
||||||
# Embeddings
|
|
||||||
'Get-OllamaEmbedding'
|
|
||||||
'Get-OllamaEmbeddings'
|
|
||||||
|
|
||||||
# RAG Utilities
|
|
||||||
'Get-CosineSimilarity'
|
|
||||||
'Group-TextsByEmbedding'
|
|
||||||
'Find-SimilarTexts'
|
|
||||||
)
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$comment": "Configuration for Generate-Changelog.ps1 (AI-assisted changelog generation)",
|
|
||||||
|
|
||||||
"ollama": {
|
|
||||||
"enabled": true,
|
|
||||||
"apiUrl": "http://localhost:11434",
|
|
||||||
"defaultTimeout": 0,
|
|
||||||
"defaultContextWindow": 0
|
|
||||||
},
|
|
||||||
|
|
||||||
"changelog": {
|
|
||||||
"debug": true,
|
|
||||||
"enableRAG": true,
|
|
||||||
"similarityThreshold": 0.65,
|
|
||||||
|
|
||||||
"csprojPath": "../MaksIT.Core/MaksIT.Core.csproj",
|
|
||||||
"outputFile": "../../CHANGELOG.md",
|
|
||||||
"licensePath": "../../LICENSE.md",
|
|
||||||
"fileExtension": ".cs",
|
|
||||||
"excludePatterns": ["Tests:", "Tests.cs", ".Tests."],
|
|
||||||
|
|
||||||
"models": {
|
|
||||||
"analyze": {
|
|
||||||
"name": "qwen2.5-coder:7b-instruct-q6_K",
|
|
||||||
"context": 0,
|
|
||||||
"maxTokens": 0,
|
|
||||||
"description": "Pass 1: Code commit analysis (7B, fast)"
|
|
||||||
},
|
|
||||||
"reason": {
|
|
||||||
"name": "qwen2.5:7b-instruct-q8_0",
|
|
||||||
"context": 0,
|
|
||||||
"maxTokens": 0,
|
|
||||||
"temperature": 0.1,
|
|
||||||
"description": "Pass 2: Consolidation (7B, fast)"
|
|
||||||
},
|
|
||||||
"write": {
|
|
||||||
"name": "qwen2.5:7b-instruct-q8_0",
|
|
||||||
"context": 0,
|
|
||||||
"maxTokens": 0,
|
|
||||||
"description": "Pass 3: Formatting (7B, fast)"
|
|
||||||
},
|
|
||||||
"embed": {
|
|
||||||
"name": "mxbai-embed-large",
|
|
||||||
"description": "RAG: Commit clustering"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"prompts": {
|
|
||||||
"analyze": [
|
|
||||||
"Convert code changes to changelog entries. Include ALL items.",
|
|
||||||
"",
|
|
||||||
"Changes:",
|
|
||||||
"{{changes}}",
|
|
||||||
"",
|
|
||||||
"RULES:",
|
|
||||||
"1. Create ONE bullet point per item",
|
|
||||||
"2. Include method names mentioned (CreateMutex, ResolveFolderPath, etc.)",
|
|
||||||
"3. New classes = \"Added [class] for [purpose]\"",
|
|
||||||
"4. New methods = \"Added [method] to [class]\"",
|
|
||||||
"5. Deleted files = \"Removed [class/feature]\"",
|
|
||||||
"6. Exception handling = \"Improved error handling in [class]\"",
|
|
||||||
"",
|
|
||||||
"Output bullet points for each change:"
|
|
||||||
],
|
|
||||||
|
|
||||||
"reason": [
|
|
||||||
"Keep all important details from this changelog.",
|
|
||||||
"",
|
|
||||||
"Input:",
|
|
||||||
"{{input}}",
|
|
||||||
"",
|
|
||||||
"RULES:",
|
|
||||||
"1. KEEP specific method names and class names",
|
|
||||||
"2. KEEP all distinct features - do not over-consolidate",
|
|
||||||
"3. Merge ONLY if items are nearly identical",
|
|
||||||
"4. DO NOT invent new information",
|
|
||||||
"5. Output 3-10 bullet points",
|
|
||||||
"",
|
|
||||||
"Output:"
|
|
||||||
],
|
|
||||||
|
|
||||||
"format": [
|
|
||||||
"Categorize these items under the correct changelog headers.",
|
|
||||||
"",
|
|
||||||
"Items:",
|
|
||||||
"{{items}}",
|
|
||||||
"",
|
|
||||||
"HEADERS (use exactly as shown):",
|
|
||||||
"### Added",
|
|
||||||
"### Changed",
|
|
||||||
"### Fixed",
|
|
||||||
"### Removed",
|
|
||||||
"",
|
|
||||||
"CATEGORIZATION RULES:",
|
|
||||||
"- \"Added [class/method]\" -> ### Added",
|
|
||||||
"- \"Improved...\" or \"Enhanced...\" -> ### Changed",
|
|
||||||
"- \"Fixed...\" -> ### Fixed",
|
|
||||||
"- \"Removed...\" -> ### Removed",
|
|
||||||
"",
|
|
||||||
"Output each item under correct header. Omit empty sections:"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user