(feature): BaseFileLogger idempotent log folder creation and tests

This commit is contained in:
Maksym Sadovnychyy 2026-02-13 20:45:55 +01:00
parent 1bfff2de76
commit 53b51223e4
11 changed files with 108 additions and 1144 deletions

View File

@ -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`

View File

@ -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);
}
} }

View File

@ -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);
}
} }

View File

@ -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);

View File

@ -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) {

View File

@ -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) {

View File

@ -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>

View File

@ -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

View File

@ -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 ""

View File

@ -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'
)

View File

@ -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:"
]
}
}
}