From 7a8c6ab9a857882f0579e38030c38bd574c3605e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 17:05:34 +0000 Subject: [PATCH] fix: address architectural issues across framework and CLI - DoctorCommand: correct project-reference path to include src\ segment so --fix produces buildable csproj entries - SlnxManipulator: remove dead FindFolderClosingTag helper - AnthropicExtensions: resolve options via IOptions in factory instead of re-reading IConfiguration, restoring PostConfigure parity with the other AI providers - VectorKnowledgeStore: build a runtime VectorStoreCollectionDefinition from RagOptions.EmbeddingDimension so callers can use models with non-1536 vectors (Ollama 768, etc.) without a silent dimension mismatch - IModule lifecycle: remove the redundant IModuleLifecycle interface; modules use the IModule default-method overrides directly, simplifying ModuleLifecycleHostedService - Generator: drop the hardcoded ModuleName == "Localization" check in HostingExtensionsEmitter and remove LocalizationExtensionsEmitter; LocalizationModule now initializes its TranslationLoader via ConfigureHost(IHost), discovering module assemblies from DI - sm new module: scaffold Views/IndexEndpoint.cs, Views/Index.tsx, Pages/index.ts, and vite.config.ts so a freshly generated module renders a working Inertia page out of the box --- .../Commands/Doctor/DoctorCommand.cs | 2 +- .../Commands/New/NewModuleCommand.cs | 24 ++++++++ .../Infrastructure/SlnxManipulator.cs | 22 ------- .../Templates/ModuleTemplates.cs | 45 ++++++++++++++ .../AnthropicExtensions.cs | 5 +- .../Hosting/ModuleLifecycleHostedService.cs | 22 ++----- .../SimpleModule.Core/IModuleLifecycle.cs | 14 ----- .../Emitters/HostingExtensionsEmitter.cs | 7 --- .../Emitters/LocalizationExtensionsEmitter.cs | 51 ---------------- .../ModuleDiscovererGenerator.cs | 1 - framework/SimpleModule.Rag/KnowledgeRecord.cs | 5 +- .../SimpleModule.Rag/VectorKnowledgeStore.cs | 61 ++++++++++++++++--- .../LocalizationModule.cs | 12 ++++ 13 files changed, 143 insertions(+), 128 deletions(-) delete mode 100644 framework/SimpleModule.Core/IModuleLifecycle.cs delete mode 100644 framework/SimpleModule.Generator/Emitters/LocalizationExtensionsEmitter.cs diff --git a/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs b/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs index 99faef6b..9c8fb680 100644 --- a/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs @@ -180,7 +180,7 @@ private static void AutoFix(SolutionContext solution, List results) var moduleName = result.Name["API -> ".Length..]; ProjectManipulator.AddProjectReference( solution.ApiCsprojPath, - $@"..\modules\{moduleName}\{moduleName}\{moduleName}.csproj" + $@"..\modules\{moduleName}\src\{moduleName}\{moduleName}.csproj" ); AnsiConsole.MarkupLine( $"[green] Fixed: added {moduleName} reference to API csproj[/]" diff --git a/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs index e87e1573..bb92395c 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs @@ -36,6 +36,8 @@ public override int Execute(CommandContext context, NewModuleSettings settings) var moduleDir = solution.GetModuleProjectPath(moduleName); var eventsDir = Path.Combine(contractsDir, "Events"); var endpointsDir = Path.Combine(moduleDir, "Endpoints", moduleName); + var viewsDir = solution.GetModuleViewsPath(moduleName); + var pagesDir = Path.Combine(moduleDir, "Pages"); var testDir = solution.GetTestProjectPath(moduleName); void Plan(string path) => ops.Add((path, FileAction.Create)); @@ -51,6 +53,10 @@ public override int Execute(CommandContext context, NewModuleSettings settings) Plan(Path.Combine(moduleDir, $"{moduleName}DbContext.cs")); Plan(Path.Combine(moduleDir, $"{singularName}Service.cs")); Plan(Path.Combine(endpointsDir, "GetAllEndpoint.cs")); + Plan(Path.Combine(viewsDir, "IndexEndpoint.cs")); + Plan(Path.Combine(viewsDir, "Index.tsx")); + Plan(Path.Combine(pagesDir, "index.ts")); + Plan(Path.Combine(moduleDir, "vite.config.ts")); Plan(Path.Combine(testDir, $"{moduleName}.Tests.csproj")); Plan(Path.Combine(testDir, "GlobalUsings.cs")); Plan(Path.Combine(testDir, "Unit", $"{singularName}ServiceTests.cs")); @@ -73,6 +79,8 @@ public override int Execute(CommandContext context, NewModuleSettings settings) { Directory.CreateDirectory(eventsDir); Directory.CreateDirectory(endpointsDir); + Directory.CreateDirectory(viewsDir); + Directory.CreateDirectory(pagesDir); Directory.CreateDirectory(Path.Combine(testDir, "Unit")); Directory.CreateDirectory(Path.Combine(testDir, "Integration")); @@ -125,6 +133,22 @@ public override int Execute(CommandContext context, NewModuleSettings settings) Path.Combine(endpointsDir, "GetAllEndpoint.cs"), templates.GetAllEndpoint(moduleName, singularName) ); + File.WriteAllText( + Path.Combine(viewsDir, "IndexEndpoint.cs"), + ModuleTemplates.IndexViewEndpoint(moduleName) + ); + File.WriteAllText( + Path.Combine(viewsDir, "Index.tsx"), + ModuleTemplates.IndexPageTsx(moduleName) + ); + File.WriteAllText( + Path.Combine(pagesDir, "index.ts"), + ModuleTemplates.PagesIndexTs(moduleName) + ); + File.WriteAllText( + Path.Combine(moduleDir, "vite.config.ts"), + ModuleTemplates.ViteConfig() + ); File.WriteAllText( Path.Combine(testDir, $"{moduleName}.Tests.csproj"), diff --git a/cli/SimpleModule.Cli/Infrastructure/SlnxManipulator.cs b/cli/SimpleModule.Cli/Infrastructure/SlnxManipulator.cs index b8f761ae..32d3fc67 100644 --- a/cli/SimpleModule.Cli/Infrastructure/SlnxManipulator.cs +++ b/cli/SimpleModule.Cli/Infrastructure/SlnxManipulator.cs @@ -74,26 +74,4 @@ private static string DetectIndent(List lines) return " "; } - - private static int FindFolderClosingTag(List lines, string folderName) - { - var folderIndex = lines.FindIndex(l => - l.Contains($"", StringComparison.Ordinal) - ); - if (folderIndex < 0) - { - return -1; - } - - // Find closing tag - for (var i = folderIndex + 1; i < lines.Count; i++) - { - if (lines[i].TrimStart().StartsWith("", StringComparison.Ordinal)) - { - return i; - } - } - - return -1; - } } diff --git a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs index b313424c..21bcdb1e 100644 --- a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs @@ -230,6 +230,51 @@ public string GlobalUsings() """; } + public static string IndexViewEndpoint(string moduleName) => + $$""" + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + using SimpleModule.Core.Inertia; + + namespace SimpleModule.{{moduleName}}.Views; + + public class IndexEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => Inertia.Render("{{moduleName}}/Index", new { })) + .ExcludeFromDescription(); + } + } + """; + + public static string PagesIndexTs(string moduleName) => + $$""" + export const pages: Record Promise> = { + '{{moduleName}}/Index': () => import('../Views/Index'), + }; + """; + + public static string IndexPageTsx(string moduleName) => + """ + export default function Index() { + return ( +
+

__MODULE_NAME__

+

Welcome to the __MODULE_NAME__ module. Edit Views/Index.tsx to customize this page.

+
+ ); + } + """.Replace("__MODULE_NAME__", moduleName, StringComparison.Ordinal); + + public static string ViteConfig() => + """ + import { defineModuleConfig } from '@simplemodule/client/module'; + + export default defineModuleConfig(import.meta.dirname); + """; + // ── Medium C# files (read + strip + rename) ───────────────────── public string ContractsInterface(string moduleName, string singularName) diff --git a/framework/SimpleModule.AI.Anthropic/AnthropicExtensions.cs b/framework/SimpleModule.AI.Anthropic/AnthropicExtensions.cs index a4f994c7..7b43cf4b 100644 --- a/framework/SimpleModule.AI.Anthropic/AnthropicExtensions.cs +++ b/framework/SimpleModule.AI.Anthropic/AnthropicExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace SimpleModule.AI.Anthropic; @@ -16,9 +17,7 @@ IConfiguration configuration services.AddSingleton(sp => { - var opts = - configuration.GetSection("AI:Anthropic").Get() - ?? new AnthropicOptions(); + var opts = sp.GetRequiredService>().Value; var client = new AnthropicClient(opts.ApiKey); return client.Messages; }); diff --git a/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs b/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs index 1dd1cd0d..61f5e103 100644 --- a/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs +++ b/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs @@ -6,8 +6,8 @@ namespace SimpleModule.Core.Hosting; /// /// Calls lifecycle hooks on all discovered modules during application startup and shutdown. -/// Supports both / (default methods) -/// and the focused interface. +/// Modules implement lifecycle by overriding / +/// default methods. /// public sealed partial class ModuleLifecycleHostedService( IEnumerable modules, @@ -31,14 +31,7 @@ public async Task StartAsync(CancellationToken cancellationToken) var moduleName = module.GetType().Name; try { - if (module is IModuleLifecycle lifecycle) - { - await lifecycle.OnStartAsync(cancellationToken); - } - else - { - await module.OnStartAsync(cancellationToken); - } + await module.OnStartAsync(cancellationToken); LogModuleStarted(logger, moduleName); } catch (OperationCanceledException) @@ -69,14 +62,7 @@ public async Task StopAsync(CancellationToken cancellationToken) var moduleName = module.GetType().Name; try { - if (module is IModuleLifecycle lifecycle) - { - await lifecycle.OnStopAsync(cancellationToken); - } - else - { - await module.OnStopAsync(cancellationToken); - } + await module.OnStopAsync(cancellationToken); LogModuleStopped(logger, moduleName); } catch (OperationCanceledException) diff --git a/framework/SimpleModule.Core/IModuleLifecycle.cs b/framework/SimpleModule.Core/IModuleLifecycle.cs deleted file mode 100644 index 36b09ec5..00000000 --- a/framework/SimpleModule.Core/IModuleLifecycle.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SimpleModule.Core; - -/// -/// Implement this interface for startup/shutdown lifecycle hooks and health probes. -/// Preferred over overriding / -/// on the module class. -/// -public interface IModuleLifecycle -{ - Task OnStartAsync(CancellationToken cancellationToken); - Task OnStopAsync(CancellationToken cancellationToken); - Task CheckHealthAsync(CancellationToken cancellationToken) => - Task.FromResult(ModuleHealthStatus.Healthy); -} diff --git a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs index a8dd8491..5c438e07 100644 --- a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs @@ -102,13 +102,6 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } } - var hasLocalizationModule = data.Modules.Any(m => m.ModuleName == "Localization"); - if (hasLocalizationModule) - { - sb.AppendLine(); - sb.AppendLine(" app.InitializeLocalization();"); - } - sb.AppendLine(); sb.AppendLine(" // Source-generated endpoint mapping"); sb.AppendLine(" app.MapModuleEndpoints();"); diff --git a/framework/SimpleModule.Generator/Emitters/LocalizationExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/LocalizationExtensionsEmitter.cs deleted file mode 100644 index 14b6e969..00000000 --- a/framework/SimpleModule.Generator/Emitters/LocalizationExtensionsEmitter.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Linq; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace SimpleModule.Generator; - -internal sealed class LocalizationExtensionsEmitter : IEmitter -{ - public void Emit(SourceProductionContext context, DiscoveryData data) - { - if (!data.Modules.Any(m => m.ModuleName == "Localization")) - { - return; - } - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine(); - sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); - sb.AppendLine("using Microsoft.Extensions.Hosting;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Hosting;"); - sb.AppendLine(); - sb.AppendLine("public static class LocalizationExtensions"); - sb.AppendLine("{"); - sb.AppendLine(" public static void InitializeLocalization(this IHost host)"); - sb.AppendLine(" {"); - sb.AppendLine( - " var loader = host.Services.GetRequiredService();" - ); - sb.AppendLine(" var assemblies = new System.Reflection.Assembly[]"); - sb.AppendLine(" {"); - - foreach (var module in data.Modules) - { - sb.AppendLine($" typeof({module.FullyQualifiedName}).Assembly,"); - } - - sb.AppendLine(" };"); - sb.AppendLine(" loader.Initialize(assemblies);"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource( - "LocalizationExtensions.g.cs", - SourceText.From(sb.ToString(), Encoding.UTF8) - ); - } -} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 8bb49255..fa8fff10 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -23,7 +23,6 @@ public class ModuleDiscovererGenerator : IIncrementalGenerator new DbContextRegistryEmitter(), new ContractRegistryEmitter(), new AgentExtensionsEmitter(), - new LocalizationExtensionsEmitter(), new RoutesEmitter(), new TypeScriptRoutesEmitter(), ]; diff --git a/framework/SimpleModule.Rag/KnowledgeRecord.cs b/framework/SimpleModule.Rag/KnowledgeRecord.cs index 09d9d92f..cc61ab45 100644 --- a/framework/SimpleModule.Rag/KnowledgeRecord.cs +++ b/framework/SimpleModule.Rag/KnowledgeRecord.cs @@ -19,6 +19,9 @@ public sealed class KnowledgeRecord [VectorStoreData(IsIndexed = true)] public string? ModuleName { get; set; } - [VectorStoreVector(1536)] + // Dimension is configured at runtime via VectorStoreCollectionDefinition built from + // RagOptions.EmbeddingDimension. The attribute default below is only used when callers + // bypass VectorKnowledgeStore and use attribute-based discovery directly. + [VectorStoreVector(VectorKnowledgeStore.DefaultEmbeddingDimension)] public ReadOnlyMemory Embedding { get; set; } } diff --git a/framework/SimpleModule.Rag/VectorKnowledgeStore.cs b/framework/SimpleModule.Rag/VectorKnowledgeStore.cs index 61b4b940..ebe7cc53 100644 --- a/framework/SimpleModule.Rag/VectorKnowledgeStore.cs +++ b/framework/SimpleModule.Rag/VectorKnowledgeStore.cs @@ -1,30 +1,65 @@ using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; using Microsoft.Extensions.VectorData; using SimpleModule.Core.Rag; namespace SimpleModule.Rag; -public sealed class VectorKnowledgeStore( - VectorStore vectorStore, - IEmbeddingGenerator> embeddingGenerator -) : IKnowledgeStore +public sealed class VectorKnowledgeStore : IKnowledgeStore { + public const int DefaultEmbeddingDimension = 1536; + + private readonly VectorStore _vectorStore; + private readonly IEmbeddingGenerator> _embeddingGenerator; + private readonly VectorStoreCollectionDefinition _definition; + + public VectorKnowledgeStore( + VectorStore vectorStore, + IEmbeddingGenerator> embeddingGenerator, + IOptions options + ) + { + _vectorStore = vectorStore; + _embeddingGenerator = embeddingGenerator; + _definition = BuildDefinition(options.Value.EmbeddingDimension); + } + + private static VectorStoreCollectionDefinition BuildDefinition(int dimension) => + new() + { + Properties = + { + new VectorStoreKeyProperty("Id", typeof(string)), + new VectorStoreDataProperty("Title", typeof(string)), + new VectorStoreDataProperty("Content", typeof(string)), + new VectorStoreDataProperty("CollectionName", typeof(string)), + new VectorStoreDataProperty("ModuleName", typeof(string)) { IsIndexed = true }, + new VectorStoreVectorProperty( + "Embedding", + typeof(ReadOnlyMemory), + dimension + ), + }, + }; + public async Task IndexDocumentsAsync( string collectionName, IReadOnlyList documents, CancellationToken cancellationToken = default ) { - var collection = vectorStore.GetCollection(collectionName); + var collection = _vectorStore.GetCollection( + collectionName, + _definition + ); await collection.EnsureCollectionExistsAsync(cancellationToken); var contents = documents.Select(d => d.Content).ToList(); - var embeddings = await embeddingGenerator.GenerateAsync( + var embeddings = await _embeddingGenerator.GenerateAsync( contents, cancellationToken: cancellationToken ); - // Upsert concurrently in batches for better throughput var upsertTasks = new List(documents.Count); for (var i = 0; i < documents.Count; i++) { @@ -52,12 +87,15 @@ public async Task> SearchAsync( CancellationToken cancellationToken = default ) { - var collection = vectorStore.GetCollection(collectionName); + var collection = _vectorStore.GetCollection( + collectionName, + _definition + ); if (!await collection.CollectionExistsAsync(cancellationToken)) return []; - var queryEmbeddings = await embeddingGenerator.GenerateAsync( + var queryEmbeddings = await _embeddingGenerator.GenerateAsync( [query], cancellationToken: cancellationToken ); @@ -96,7 +134,10 @@ public async Task DeleteCollectionAsync( CancellationToken cancellationToken = default ) { - var collection = vectorStore.GetCollection(collectionName); + var collection = _vectorStore.GetCollection( + collectionName, + _definition + ); await collection.EnsureCollectionDeletedAsync(cancellationToken); } } diff --git a/modules/Localization/src/SimpleModule.Localization/LocalizationModule.cs b/modules/Localization/src/SimpleModule.Localization/LocalizationModule.cs index d95c8c82..d2819160 100644 --- a/modules/Localization/src/SimpleModule.Localization/LocalizationModule.cs +++ b/modules/Localization/src/SimpleModule.Localization/LocalizationModule.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Localization; using SimpleModule.Core; using SimpleModule.Localization.Contracts; @@ -26,4 +27,15 @@ public void ConfigureMiddleware(IApplicationBuilder app) { app.UseMiddleware(); } + + public void ConfigureHost(IHost host) + { + var loader = host.Services.GetRequiredService(); + var assemblies = host + .Services.GetServices() + .Select(m => m.GetType().Assembly) + .Distinct() + .ToArray(); + loader.Initialize(assemblies); + } }