Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8f6617da6 | ||
|
|
53b51223e4 |
17
CHANGELOG.md
17
CHANGELOG.md
@ -5,7 +5,22 @@ 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).
|
||||
|
||||
## v1.6.1
|
||||
## v1.6.3 - 2026-02-13
|
||||
|
||||
### Changed
|
||||
- Updated dependencies to latest versions for improved performance and security.
|
||||
|
||||
## 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 `CreateMutex` method to `BaseFileLogger`
|
||||
|
||||
@ -217,4 +217,42 @@ public class FileLoggerTests {
|
||||
var logContent = File.ReadAllText(logFile);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -14,13 +14,13 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -45,6 +45,18 @@ public abstract class BaseFileLogger : ILogger, IDisposable {
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
mutexAcquired = _fileMutex.WaitOne(10000);
|
||||
|
||||
@ -20,11 +20,13 @@ public class FileLoggerProvider : ILoggerProvider {
|
||||
private string ResolveFolderPath(string categoryName) {
|
||||
var (prefix, value) = LoggerPrefix.Parse(categoryName);
|
||||
|
||||
var newFolderPath = _folderPath;
|
||||
|
||||
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) {
|
||||
|
||||
@ -20,11 +20,13 @@ public class JsonFileLoggerProvider : ILoggerProvider {
|
||||
private string ResolveFolderPath(string categoryName) {
|
||||
var (prefix, value) = LoggerPrefix.Parse(categoryName);
|
||||
|
||||
var newFolderPath = _folderPath;
|
||||
|
||||
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) {
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>MaksIT.Core</PackageId>
|
||||
<Version>1.6.1</Version>
|
||||
<Version>1.6.3</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Core</Product>
|
||||
@ -48,20 +48,20 @@
|
||||
|
||||
<!-- Source Link package -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.103" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.9" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.7.1" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -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