diff --git a/src/Memorizer.IntegrationTests/HybridSearchIntegrationTests.cs b/src/Memorizer.IntegrationTests/HybridSearchIntegrationTests.cs new file mode 100644 index 0000000..70e21ea --- /dev/null +++ b/src/Memorizer.IntegrationTests/HybridSearchIntegrationTests.cs @@ -0,0 +1,124 @@ +using Memorizer.Extensions; +using Memorizer.Models; +using Memorizer.Models.ValueTypes; +using Memorizer.Services; +using Memorizer.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Memorizer.IntegrationTests; + +/// +/// Integration tests for the hybrid search behavior that the web UI now uses by +/// default (see ADR 2026-02-14-hybrid-search-rrf.md): short keyword queries that +/// fail with metadata-embedding vector search at the default threshold are still +/// found via the full-text leg. +/// +[Collection(nameof(IntegrationTestCollection))] +public class HybridSearchIntegrationTests : IDisposable +{ + private readonly IntegrationTestFixture _fixture; + private readonly IServiceProvider _services; + + public void Dispose() + { + (_services as IDisposable)?.Dispose(); + } + + public HybridSearchIntegrationTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _services = CreateServices(); + } + + private IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + + services.AddSingleton(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Storage"] = _fixture.PostgresConnectionString, + ["Embeddings:ApiUrl"] = _fixture.OllamaApiUrl, + ["Embeddings:Model"] = "all-minilm", + ["Embeddings:Timeout"] = TimeSpan.FromMinutes(1).ToString() + }) + .Build()); + + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(_fixture.OllamaApiUrl); + client.Timeout = TimeSpan.FromMinutes(1); + }); + + services.AddSingleton(new EmbeddingSettings + { + ApiUrl = new Uri(_fixture.OllamaApiUrl), + Model = "all-minilm", + Timeout = TimeSpan.FromMinutes(1) + }); + + services.AddMemorizer(); + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + [Fact] + public async Task HybridSearch_FindsShortKeywordQueries_ThatVectorSearchMisses() + { + // Arrange - the ADR scenario: short keyword queries fail at the default threshold + var storage = _services.GetRequiredService(); + + var created = new List(); + try + { + created.Add((await storage.StoreMemory( + "reference", "Notes about race conditions in concurrent code and mutex handling", "test", + new[] { "concurrency" }, new Confidence(1.0), "Race condition deep dive")).Id); + created.Add((await storage.StoreMemory( + "reference", "Understanding dependency injection containers and service lifetimes", "test", + new[] { "di" }, new Confidence(1.0), "Dependency injection")).Id); + + // Act - hybrid search should surface exact keyword matches via the FTS leg + var hybridResults = await storage.HybridSearch("race condition", limit: 5, minSimilarity: null, filterTags: null); + + // Assert + Assert.NotEmpty(hybridResults); + Assert.Contains(hybridResults, m => m.Title?.Contains("Race condition") == true); + } + finally + { + foreach (var id in created) + await storage.Delete(id); + } + } + + [Fact] + public async Task VectorSearch_StillReturnsResults_ForShortKeywordQueries() + { + // Arrange + var storage = _services.GetRequiredService(); + + var created = new List(); + try + { + created.Add((await storage.StoreMemory( + "reference", "Notes about race conditions in concurrent code and mutex handling", "test", + new[] { "concurrency" }, new Confidence(1.0), "Race condition deep dive")).Id); + + // Act - method=vector mode must still work (metadata embedding search) + var vectorResults = await storage.SearchWithMetadataEmbedding( + "race condition", limit: 5, new SimilarityScore(0.3), filterTags: null); + + // Assert - a lenient threshold should still surface the result + Assert.Contains(vectorResults, m => m.Title?.Contains("Race condition") == true); + } + finally + { + foreach (var id in created) + await storage.Delete(id); + } + } +} diff --git a/src/Memorizer.IntegrationTests/TagCloudIntegrationTests.cs b/src/Memorizer.IntegrationTests/TagCloudIntegrationTests.cs new file mode 100644 index 0000000..b2eefa2 --- /dev/null +++ b/src/Memorizer.IntegrationTests/TagCloudIntegrationTests.cs @@ -0,0 +1,175 @@ +using Memorizer.Extensions; +using Memorizer.Models; +using Memorizer.Models.Enums; +using Memorizer.Models.ValueTypes; +using Memorizer.Services; +using Memorizer.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Memorizer.IntegrationTests; + +/// +/// Integration tests for the tag cloud service (ITagCloudService). +/// +[Collection(nameof(IntegrationTestCollection))] +public class TagCloudIntegrationTests : IDisposable +{ + private readonly IntegrationTestFixture _fixture; + private readonly IServiceProvider _services; + + public void Dispose() + { + (_services as IDisposable)?.Dispose(); + } + + public TagCloudIntegrationTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _services = CreateServices(); + } + + private IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + + services.AddSingleton(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Storage"] = _fixture.PostgresConnectionString, + ["Embeddings:ApiUrl"] = _fixture.OllamaApiUrl, + ["Embeddings:Model"] = "all-minilm", + ["Embeddings:Timeout"] = TimeSpan.FromMinutes(1).ToString() + }) + .Build()); + + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(_fixture.OllamaApiUrl); + client.Timeout = TimeSpan.FromMinutes(1); + }); + + services.AddSingleton(new EmbeddingSettings + { + ApiUrl = new Uri(_fixture.OllamaApiUrl), + Model = "all-minilm", + Timeout = TimeSpan.FromMinutes(1) + }); + + services.AddMemorizer(); + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + [Fact] + public async Task WorkspaceSubtreeTagCounts_AggregateProjectsAndNestedWorkspaces() + { + // Arrange + var storage = _services.GetRequiredService(); + var tagCloud = _services.GetRequiredService(); + + var workspace = await storage.CreateWorkspaceAsync("TagCloud Root", "root"); + var project = await storage.CreateProjectAsync(workspace.Id, "TagCloud Project", "p"); + var nested = await storage.CreateWorkspaceAsync("TagCloud Nested", "n", parentId: workspace.Id); + + var created = new List(); + try + { + created.Add((await storage.StoreMemory( + "reference", "workspace direct memory", "test", new[] { "alpha" }, + new Confidence(1.0), "Workspace Memory", owner: MemoryOwner.ForWorkspace(workspace.Id))).Id); + created.Add((await storage.StoreMemory( + "reference", "project memory", "test", new[] { "beta" }, + new Confidence(1.0), "Project Memory", owner: MemoryOwner.ForProject(project.Id))).Id); + created.Add((await storage.StoreMemory( + "reference", "nested workspace memory", "test", new[] { "alpha", "gamma" }, + new Confidence(1.0), "Nested Memory", owner: MemoryOwner.ForWorkspace(nested.Id))).Id); + + // Act - subtree covers direct + project + nested workspace tags + var subtreeCounts = await tagCloud.GetWorkspaceSubtreeTagCountsAsync(workspace.Id); + + // Assert + Assert.Contains(subtreeCounts, x => x.Tag == "alpha" && x.Count == 2); + Assert.Contains(subtreeCounts, x => x.Tag == "beta" && x.Count == 1); + Assert.Contains(subtreeCounts, x => x.Tag == "gamma" && x.Count == 1); + } + finally + { + foreach (var id in created) + await storage.Delete(id); + await storage.DeleteProjectAsync(project.Id); + await storage.DeleteWorkspaceAsync(nested.Id); + await storage.DeleteWorkspaceAsync(workspace.Id); + } + } + + [Fact] + public async Task ProjectTagCounts_OnlyIncludeProjectMemories() + { + // Arrange + var storage = _services.GetRequiredService(); + var tagCloud = _services.GetRequiredService(); + + var workspace = await storage.CreateWorkspaceAsync("TagCloud Root", "root"); + var project = await storage.CreateProjectAsync(workspace.Id, "TagCloud Project", "p"); + + var created = new List(); + try + { + created.Add((await storage.StoreMemory( + "reference", "workspace memory", "test", new[] { "alpha" }, + new Confidence(1.0), "Workspace Memory", owner: MemoryOwner.ForWorkspace(workspace.Id))).Id); + created.Add((await storage.StoreMemory( + "reference", "project memory", "test", new[] { "beta" }, + new Confidence(1.0), "Project Memory", owner: MemoryOwner.ForProject(project.Id))).Id); + + // Act + var projectCounts = await tagCloud.GetProjectTagCountsAsync(project.Id); + + // Assert - only the project's own tag, not the workspace's + var beta = Assert.Single(projectCounts); + Assert.Equal("beta", beta.Tag); + Assert.Equal(1, beta.Count); + } + finally + { + foreach (var id in created) + await storage.Delete(id); + await storage.DeleteProjectAsync(project.Id); + await storage.DeleteWorkspaceAsync(workspace.Id); + } + } + + [Fact] + public async Task GlobalTagCounts_IncludeAllNonArchivedMemories() + { + // Arrange + var storage = _services.GetRequiredService(); + var tagCloud = _services.GetRequiredService(); + + var created = new List(); + try + { + created.Add((await storage.StoreMemory( + "reference", "global memory one", "test", new[] { "global-tag" }, + new Confidence(1.0), "Global One")).Id); + created.Add((await storage.StoreMemory( + "reference", "global memory two", "test", new[] { "global-tag", "other-tag" }, + new Confidence(1.0), "Global Two")).Id); + + // Act + var globalCounts = await tagCloud.GetGlobalTagCountsAsync(); + + // Assert - counts aggregate across all memories + Assert.Contains(globalCounts, x => x.Tag == "global-tag" && x.Count == 2); + Assert.Contains(globalCounts, x => x.Tag == "other-tag" && x.Count == 1); + } + finally + { + foreach (var id in created) + await storage.Delete(id); + } + } +} diff --git a/src/Memorizer.UnitTests/Tools/MemoryToolsCanonicalUrlTests.cs b/src/Memorizer.UnitTests/Tools/MemoryToolsCanonicalUrlTests.cs index 7ac4cd8..9377870 100644 --- a/src/Memorizer.UnitTests/Tools/MemoryToolsCanonicalUrlTests.cs +++ b/src/Memorizer.UnitTests/Tools/MemoryToolsCanonicalUrlTests.cs @@ -227,14 +227,13 @@ public async Task RestSearchMemories_WithWorkspaceId_PassesWorkspaceScopeToStora { // Arrange var workspaceId = Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa"); + var memory = CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Memory 1"); + memory.Similarity = new SimilarityScore(0.9); var fakeStorage = new FakeStorage { - SearchWithMetadataEmbeddingResults = new List - { - CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Memory 1") - } + HybridSearchResults = new List { memory } }; - var controller = new MemoryController(fakeStorage, new SimilaritySettings()); + var controller = new MemoryController(fakeStorage, new SimilaritySettings(), new FakeTagCloudService()); // Act var result = await controller.SearchMemories("test query", workspaceId: workspaceId); @@ -243,9 +242,61 @@ public async Task RestSearchMemories_WithWorkspaceId_PassesWorkspaceScopeToStora var ok = Assert.IsType(result.Result); var items = Assert.IsType>(ok.Value); Assert.Single(items); - Assert.True(fakeStorage.SearchWithMetadataEmbeddingCalled); - Assert.Null(fakeStorage.LastMetadataSearchProjectId); - Assert.Equal(workspaceId, fakeStorage.LastMetadataSearchWorkspaceId?.Value); + Assert.True(fakeStorage.HybridSearchCalled); + Assert.Null(fakeStorage.LastHybridSearchProjectId); + Assert.Equal(workspaceId, fakeStorage.LastHybridSearchWorkspaceId?.Value); + } + + [Fact] + public async Task RestSearchMemories_HybridWithMinSimilarity_FiltersLowSimilarityResults() + { + // Arrange + var highSim = CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "High"); + highSim.Similarity = new SimilarityScore(0.9); + var lowSim = CreateTestMemory(new MemoryId(Guid.Parse("b2c3d4e5-f6a7-8901-bcde-f12345678901")), "Low"); + lowSim.Similarity = new SimilarityScore(0.5); + var noSim = CreateTestMemory(new MemoryId(Guid.Parse("c3d4e5f6-a7b8-901c-def1-23456789012a")), "No Score"); + + var fakeStorage = new FakeStorage + { + HybridSearchResults = new List { highSim, lowSim, noSim } + }; + var controller = new MemoryController(fakeStorage, new SimilaritySettings(), new FakeTagCloudService()); + + // Act - with a threshold, only results at/above it survive; no-score results are excluded + var result = await controller.SearchMemories("test query", minSimilarity: 0.7); + + // Assert + var ok = Assert.IsType(result.Result); + var items = Assert.IsType>(ok.Value); + var returned = Assert.Single(items); + Assert.Equal(highSim.Id.Value, returned.Id); + } + + [Fact] + public async Task RestSearchMemories_HybridWithoutMinSimilarity_UsesDefaultThreshold() + { + // Arrange + var aboveDefault = CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Above Default"); + aboveDefault.Similarity = new SimilarityScore(0.4); + var belowDefault = CreateTestMemory(new MemoryId(Guid.Parse("b2c3d4e5-f6a7-8901-bcde-f12345678901")), "Below Default"); + belowDefault.Similarity = new SimilarityScore(0.1); + var noSim = CreateTestMemory(new MemoryId(Guid.Parse("c3d4e5f6-a7b8-901c-def1-23456789012a")), "No Score"); + + var fakeStorage = new FakeStorage + { + HybridSearchResults = new List { aboveDefault, belowDefault, noSim } + }; + var controller = new MemoryController(fakeStorage, new SimilaritySettings(), new FakeTagCloudService()); + + // Act - the default threshold (0.25) filters out low/no-score noise + var result = await controller.SearchMemories("test query"); + + // Assert + var ok = Assert.IsType(result.Result); + var items = Assert.IsType>(ok.Value); + var returned = Assert.Single(items); + Assert.Equal(aboveDefault.Id.Value, returned.Id); } [Fact] @@ -260,7 +311,7 @@ public async Task RestSearchWithMetadataEmbedding_WithWorkspaceId_PassesWorkspac CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Memory 1") } }; - var controller = new MemoryController(fakeStorage, new SimilaritySettings()); + var controller = new MemoryController(fakeStorage, new SimilaritySettings(), new FakeTagCloudService()); // Act var result = await controller.SearchWithMetadataEmbedding("test query", workspaceId: workspaceId); @@ -279,7 +330,7 @@ public async Task RestSearchMemories_WithProjectIdAndWorkspaceId_ReturnsBadReque { // Arrange var fakeStorage = new FakeStorage(); - var controller = new MemoryController(fakeStorage, new SimilaritySettings()); + var controller = new MemoryController(fakeStorage, new SimilaritySettings(), new FakeTagCloudService()); // Act var result = await controller.SearchMemories( @@ -290,7 +341,7 @@ public async Task RestSearchMemories_WithProjectIdAndWorkspaceId_ReturnsBadReque // Assert var badRequest = Assert.IsType(result.Result); Assert.Contains("mutually exclusive", Assert.IsType(badRequest.Value)); - Assert.False(fakeStorage.SearchWithMetadataEmbeddingCalled); + Assert.False(fakeStorage.HybridSearchCalled); } [Fact] @@ -375,6 +426,23 @@ private static Memory CreateTestMemory(MemoryId id, string title) }; } + /// + /// Minimal ITagCloudService stub for controller constructor tests. + /// + private class FakeTagCloudService : ITagCloudService + { + public Task> GetWorkspaceSubtreeTagCountsAsync( + WorkspaceId workspaceId, CancellationToken cancellationToken = default) + => Task.FromResult(new List()); + + public Task> GetProjectTagCountsAsync( + ProjectId projectId, CancellationToken cancellationToken = default) + => Task.FromResult(new List()); + + public Task> GetGlobalTagCountsAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new List()); + } + /// /// Fake storage implementing IStorage with just enough for memory tool tests /// diff --git a/src/Memorizer/Controllers/MemoryController.cs b/src/Memorizer/Controllers/MemoryController.cs index c2a0ae6..12281fb 100644 --- a/src/Memorizer/Controllers/MemoryController.cs +++ b/src/Memorizer/Controllers/MemoryController.cs @@ -17,11 +17,13 @@ public class MemoryController : ControllerBase { private readonly IStorage _storage; private readonly SimilaritySettings _similaritySettings; + private readonly ITagCloudService _tagCloudService; - public MemoryController(IStorage storage, SimilaritySettings similaritySettings) + public MemoryController(IStorage storage, SimilaritySettings similaritySettings, ITagCloudService tagCloudService) { _storage = storage; _similaritySettings = similaritySettings; + _tagCloudService = tagCloudService; } /// @@ -136,6 +138,36 @@ public async Task>> GetDistinctTags( return Ok(tags); } + /// + /// Get tag counts for a tag cloud. Scope to a workspace subtree or a single project + /// with the optional query parameters, or omit both for global counts across all + /// non-archived memories. Workspace scope aggregates across the entire subtree. + /// + [HttpGet("tags/cloud")] + public async Task>> GetTagCloud( + [FromQuery] Guid? workspaceId = null, + [FromQuery] Guid? projectId = null, + CancellationToken cancellationToken = default) + { + if (projectId.HasValue && workspaceId.HasValue) + return BadRequest("projectId and workspaceId are mutually exclusive tag cloud scopes."); + + if (projectId.HasValue) + { + var counts = await _tagCloudService.GetProjectTagCountsAsync(new ProjectId(projectId.Value), cancellationToken); + return Ok(counts); + } + + if (workspaceId.HasValue) + { + var counts = await _tagCloudService.GetWorkspaceSubtreeTagCountsAsync(new WorkspaceId(workspaceId.Value), cancellationToken); + return Ok(counts); + } + + var globalCounts = await _tagCloudService.GetGlobalTagCountsAsync(cancellationToken); + return Ok(globalCounts); + } + /// /// Get distinct owner (type, id) pairs for memories matching the given filters /// @@ -530,18 +562,27 @@ private static bool TagsAreEqual(string[]? tags1, string[]? tags2) } /// - /// Vector search for memories using metadata embeddings (optimized for keyword queries) + /// Search for memories. Defaults to hybrid search (vector + full-text search via RRF), + /// which performs significantly better for short keyword queries (see ADR + /// 2026-02-14-hybrid-search-rrf.md). Pass method=vector to use metadata-embedding + /// vector search only. + /// + /// For hybrid search, minSimilarity filters results by vector similarity: results at or + /// above the threshold are returned (full-text-only matches, which have no similarity + /// score, are excluded). Defaults to 0.25 to filter out noise; pass 0 to disable. + /// For vector search, minSimilarity is the similarity threshold (default 0.7). /// [HttpGet("search")] public async Task>> SearchMemories( [FromQuery] string query, - [FromQuery] double minSimilarity = 0.7, + [FromQuery] double? minSimilarity = null, [FromQuery] int limit = 10, [FromQuery] string[]? filterTags = null, [FromQuery] Guid? projectId = null, [FromQuery] bool includeUnassigned = false, [FromQuery] bool includeArchived = false, - [FromQuery] Guid? workspaceId = null) + [FromQuery] Guid? workspaceId = null, + [FromQuery] string? method = "hybrid") { if (string.IsNullOrWhiteSpace(query)) return BadRequest("Query is required."); @@ -552,18 +593,48 @@ public async Task>> SearchMemories( ProjectId? typedProjectId = projectId.HasValue ? new ProjectId(projectId.Value) : null; WorkspaceId? typedWorkspaceId = workspaceId.HasValue ? new WorkspaceId(workspaceId.Value) : null; - // Use metadata embeddings by default for better keyword query performance - var results = await _storage.SearchWithMetadataEmbedding( + bool useHybrid = string.IsNullOrWhiteSpace(method) || method.Equals("hybrid", StringComparison.OrdinalIgnoreCase); + + if (useHybrid) + { + // HybridSearch intentionally does not apply a similarity threshold internally + // (see ADR 2026-02-14) so short keyword queries still surface full-text matches. + // Apply the threshold here as a post-filter on vector similarity, defaulting to + // 0.25 to filter out noise. + var results = await _storage.HybridSearch( + query, + limit, + minSimilarity: null, + filterTags, + typedProjectId, + includeUnassigned, + includeArchived, + includeSystem: false, + workspaceId: typedWorkspaceId); + + double threshold = minSimilarity.GetValueOrDefault(0.25); + if (threshold > 0) + { + results = results + .Where(m => m.Similarity.HasValue && m.Similarity.Value >= threshold) + .ToList(); + } + + return Ok(results.Select(MemoryListItem.FromMemory).ToList()); + } + + var vectorResults = await _storage.SearchWithMetadataEmbedding( query, limit, - new SimilarityScore(minSimilarity), + new SimilarityScore(minSimilarity ?? 0.7), filterTags, typedProjectId, includeUnassigned, includeArchived, includeSystem: false, workspaceId: typedWorkspaceId); - return Ok(results.Select(MemoryListItem.FromMemory).ToList()); + + return Ok(vectorResults.Select(MemoryListItem.FromMemory).ToList()); } /// diff --git a/src/Memorizer/Models/TagCount.cs b/src/Memorizer/Models/TagCount.cs new file mode 100644 index 0000000..07f5e16 --- /dev/null +++ b/src/Memorizer/Models/TagCount.cs @@ -0,0 +1,10 @@ +namespace Memorizer.Models; + +/// +/// A single tag and the number of non-archived memories using it. +/// +public class TagCount +{ + public string Tag { get; init; } = ""; + public int Count { get; init; } +} diff --git a/src/Memorizer/Services/ITagCloudService.cs b/src/Memorizer/Services/ITagCloudService.cs new file mode 100644 index 0000000..dce5dbf --- /dev/null +++ b/src/Memorizer/Services/ITagCloudService.cs @@ -0,0 +1,30 @@ +using Memorizer.Models; +using Memorizer.Models.ValueTypes; + +namespace Memorizer.Services; + +/// +/// Computes tag clouds (tag → memory count) scoped to workspaces and projects. +/// +public interface ITagCloudService +{ + /// + /// Gets tag counts across the entire workspace subtree: direct workspace + /// memories, all projects, and all nested workspaces (recursive). + /// + Task> GetWorkspaceSubtreeTagCountsAsync( + WorkspaceId workspaceId, + CancellationToken cancellationToken = default); + + /// + /// Gets tag counts for memories directly owned by a single project. + /// + Task> GetProjectTagCountsAsync( + ProjectId projectId, + CancellationToken cancellationToken = default); + + /// + /// Gets tag counts across all non-archived memories (no owner scope). + /// + Task> GetGlobalTagCountsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Memorizer/Services/TagCloudService.cs b/src/Memorizer/Services/TagCloudService.cs new file mode 100644 index 0000000..e9b0272 --- /dev/null +++ b/src/Memorizer/Services/TagCloudService.cs @@ -0,0 +1,104 @@ +using Memorizer.Models; +using Memorizer.Models.Enums; +using Memorizer.Models.ValueTypes; +using Npgsql; +using Registrator.Net; + +namespace Memorizer.Services; + +/// +/// Computes tag clouds (tag → memory count) scoped to workspaces and projects. +/// +[AutoRegisterInterfaces(ServiceLifetime.Scoped)] +public class TagCloudService : ITagCloudService +{ + private readonly NpgsqlDataSource _dataSource; + + public TagCloudService(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task> GetWorkspaceSubtreeTagCountsAsync( + WorkspaceId workspaceId, + CancellationToken cancellationToken = default) + { + const string sql = @" + WITH RECURSIVE ws_tree AS ( + SELECT id FROM workspaces WHERE id = @root + UNION ALL + SELECT w.id FROM workspaces w JOIN ws_tree t ON w.parent_id = t.id + ) + SELECT tag, COUNT(*) AS cnt + FROM memories, unnest(tags) AS tag + WHERE archetype IN (0, 1) + AND ( + (owner_type = @workspaceType AND owner_id IN (SELECT id FROM ws_tree)) + OR (owner_type = @projectType AND owner_id IN ( + SELECT id FROM projects WHERE workspace_id IN (SELECT id FROM ws_tree))) + ) + GROUP BY tag + ORDER BY cnt DESC, tag"; + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue("root", workspaceId.Value); + command.Parameters.AddWithValue("workspaceType", (short)OwnerTypeEnum.Workspace); + command.Parameters.AddWithValue("projectType", (short)OwnerTypeEnum.Project); + + return await ReadTagCountsAsync(command, cancellationToken); + } + + public async Task> GetProjectTagCountsAsync( + ProjectId projectId, + CancellationToken cancellationToken = default) + { + const string sql = @" + SELECT tag, COUNT(*) AS cnt + FROM memories, unnest(tags) AS tag + WHERE archetype IN (0, 1) + AND owner_type = @projectType + AND owner_id = @projectId + GROUP BY tag + ORDER BY cnt DESC, tag"; + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue("projectId", projectId.Value); + command.Parameters.AddWithValue("projectType", (short)OwnerTypeEnum.Project); + + return await ReadTagCountsAsync(command, cancellationToken); + } + + public async Task> GetGlobalTagCountsAsync(CancellationToken cancellationToken = default) + { + const string sql = @" + SELECT tag, COUNT(*) AS cnt + FROM memories, unnest(tags) AS tag + WHERE archetype IN (0, 1) + GROUP BY tag + ORDER BY cnt DESC, tag"; + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var command = new NpgsqlCommand(sql, connection); + + return await ReadTagCountsAsync(command, cancellationToken); + } + + private static async Task> ReadTagCountsAsync( + NpgsqlCommand command, + CancellationToken cancellationToken) + { + var result = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new TagCount + { + Tag = reader.GetString(0), + Count = reader.GetInt32(1) + }); + } + return result; + } +} diff --git a/src/Memorizer/Views/Home/Index.cshtml b/src/Memorizer/Views/Home/Index.cshtml index 1e5970b..6985575 100644 --- a/src/Memorizer/Views/Home/Index.cshtml +++ b/src/Memorizer/Views/Home/Index.cshtml @@ -8,7 +8,7 @@ Memory Manager -

Manage your AI agent memories with vector search capabilities

+

Manage your AI agent memories with semantic and hybrid search

@@ -58,19 +58,29 @@ - +
-
-
- + +
+
-
- +
+ +
+
+
+ + 25% +
@@ -203,9 +213,9 @@ function parseUrlParams() { const searchQuery = params.get('q'); if (searchQuery) { document.getElementById('vectorSearchQuery').value = searchQuery; - // Auto-trigger vector search + // Auto-trigger search setTimeout(() => { - runVectorSearch(new Event('submit')); + runSearch(new Event('submit')); }, 100); } } @@ -671,6 +681,8 @@ function updateFilterUrl() { // Load memories on page load document.addEventListener('DOMContentLoaded', function() { + onSearchModeChange(); + onMinSimChange(); parseUrlParams(); updateFilterContext(); loadLocationDropdown(); @@ -905,10 +917,33 @@ async function deleteMemory(id) { } } -async function runVectorSearch(event) { +function onMinSimChange() { + const value = document.getElementById('vectorSearchMinSim').value; + document.getElementById('minSimValue').textContent = value + '%'; +} + +function onSearchModeChange() { + const mode = document.getElementById('searchMode').value; + const buttonLabel = document.getElementById('searchButtonLabel'); + const input = document.getElementById('vectorSearchQuery'); + const minSimInput = document.getElementById('vectorSearchMinSim'); + + if (mode === 'vector') { + buttonLabel.textContent = 'Vector Search'; + input.placeholder = 'Vector search memories...'; + minSimInput.value = '70'; + } else { + buttonLabel.textContent = 'Search'; + input.placeholder = 'Search memories...'; + minSimInput.value = '25'; + } + onMinSimChange(); +} + +async function runSearch(event) { event.preventDefault(); const query = document.getElementById('vectorSearchQuery').value.trim(); - const minSim = parseFloat(document.getElementById('vectorSearchMinSim').value) || 0.7; + const method = document.getElementById('searchMode').value; if (!query) { // If search bar is cleared, revert to default list isVectorSearch = false; @@ -919,21 +954,24 @@ async function runVectorSearch(event) { isVectorSearch = true; document.getElementById('vectorSearchResults').innerHTML = `
Searching...
`; try { - const response = await fetch(`/api/memory/search?query=${encodeURIComponent(query)}&minSimilarity=${minSim}&limit=20`); + const minSim = (parseFloat(document.getElementById('vectorSearchMinSim').value) || 0) / 100; + const url = `/api/memory/search?query=${encodeURIComponent(query)}&method=${method}&limit=20&minSimilarity=${minSim}`; + const response = await fetch(url); const results = await response.json(); - displayVectorSearchResults(results); + displayVectorSearchResults(results, method); } catch (error) { - document.getElementById('vectorSearchResults').innerHTML = `
Error running vector search. Please try again.
`; + document.getElementById('vectorSearchResults').innerHTML = `
Error running search. Please try again.
`; } return false; } -function displayVectorSearchResults(results) { +function displayVectorSearchResults(results, method) { + const resultsTitle = method === 'vector' ? 'Vector Search Results' : 'Search Results'; if (!results || results.length === 0) { - document.getElementById('vectorSearchResults').innerHTML = `
No vector search results found.
`; + document.getElementById('vectorSearchResults').innerHTML = `
No search results found.
`; return; } - let html = `
Vector Search Results
`; + let html = `
${resultsTitle}
`; results.forEach(memory => { const tags = createClickableTagBadges(memory.tags); const title = memory.title || 'Untitled Memory'; diff --git a/src/Memorizer/Views/Home/ProjectDetail.cshtml b/src/Memorizer/Views/Home/ProjectDetail.cshtml index b799e1c..c33c3b6 100644 --- a/src/Memorizer/Views/Home/ProjectDetail.cshtml +++ b/src/Memorizer/Views/Home/ProjectDetail.cshtml @@ -46,6 +46,23 @@
+ + +
Status: @@ -411,6 +428,59 @@ function renderProject() { archiveBtn.innerHTML = 'Archive Project'; archiveBtn.onclick = archiveProject; } + + // Tag cloud + loadTagCloud(); +} + +const tagCloudMaxTags = 30; +let allTagCounts = []; +let tagCloudShowAll = false; + +async function loadTagCloud() { + const section = document.getElementById('tagCloudSection'); + const empty = document.getElementById('noTagCloud'); + + try { + const response = await fetch(`/api/memory/tags/cloud?projectId=${projectId}`); + if (!response.ok) throw new Error('Failed to load tag cloud'); + + const tags = await response.json(); + if (!tags || tags.length === 0) { + section.style.display = 'none'; + return; + } + + allTagCounts = tags; + tagCloudShowAll = false; + renderTagCloud(); + empty.style.display = 'none'; + section.style.display = 'block'; + } catch (error) { + console.warn('Could not load tag cloud:', error); + section.style.display = 'none'; + } +} + +function renderTagCloud() { + const container = document.getElementById('tagCloud'); + const visible = tagCloudShowAll ? allTagCounts : allTagCounts.slice(0, tagCloudMaxTags); + let html = visible.map(renderTagCloudItem).join(''); + if (allTagCounts.length > tagCloudMaxTags) { + html += `${tagCloudShowAll ? 'Show fewer' : `Show all ${allTagCounts.length} tags`}`; + } + container.innerHTML = html; +} + +function toggleTagCloud() { + tagCloudShowAll = !tagCloudShowAll; + renderTagCloud(); +} + +function renderTagCloudItem(tagCount) { + const fontSize = 0.8 + Math.min(0.8, (tagCount.count - 1) * 0.15); + const href = `/memories?tag=${encodeURIComponent(tagCount.tag)}`; + return `${escapeHtml(tagCount.tag)}`; } function renderStatusDropdown() { diff --git a/src/Memorizer/Views/Home/WorkspaceDetail.cshtml b/src/Memorizer/Views/Home/WorkspaceDetail.cshtml index ae3a4fd..7aa9d82 100644 --- a/src/Memorizer/Views/Home/WorkspaceDetail.cshtml +++ b/src/Memorizer/Views/Home/WorkspaceDetail.cshtml @@ -28,57 +28,70 @@
-
- - +
+
+ + +
+
+ + +

+ + + -
+

Nested Workspaces 0

-
-
-
+ -
+

Projects 0

-
-
-
+
@@ -318,24 +331,85 @@ function renderWorkspace() { // Child Workspaces const childWorkspaces = workspace.childWorkspaces || []; - document.getElementById('childWorkspaceCount').textContent = childWorkspaces.length; + const childWorkspacesSection = document.getElementById('childWorkspacesSection'); if (childWorkspaces.length > 0) { + document.getElementById('childWorkspaceCount').textContent = childWorkspaces.length; document.getElementById('childWorkspacesList').innerHTML = childWorkspaces.map(renderChildWorkspaceCard).join(''); + childWorkspacesSection.style.display = 'block'; + document.getElementById('childWorkspacesDivider').style.display = 'block'; } else { - document.getElementById('noChildWorkspaces').style.display = 'block'; + childWorkspacesSection.style.display = 'none'; + document.getElementById('childWorkspacesDivider').style.display = 'none'; } // Projects - document.getElementById('projectCount').textContent = workspace.projects.length; + const projectsSection = document.getElementById('projectsSection'); if (workspace.projects.length > 0) { + document.getElementById('projectCount').textContent = workspace.projects.length; document.getElementById('projectsList').innerHTML = workspace.projects.map(renderProjectCard).join(''); + projectsSection.style.display = 'block'; + document.getElementById('projectsDivider').style.display = 'block'; } else { - document.getElementById('noProjects').style.display = 'block'; + projectsSection.style.display = 'none'; + document.getElementById('projectsDivider').style.display = 'none'; } // Memories document.getElementById('viewAllMemoriesLink').href = `/memories?workspaceId=${workspaceId}`; renderMemoriesList(); + + // Tag cloud + loadTagCloud(); +} + +const tagCloudMaxTags = 30; +let allTagCounts = []; +let tagCloudShowAll = false; + +async function loadTagCloud() { + const section = document.getElementById('tagCloudSection'); + const empty = document.getElementById('noTagCloud'); + + try { + const response = await fetch(`/api/memory/tags/cloud?workspaceId=${workspaceId}`); + if (!response.ok) throw new Error('Failed to load tag cloud'); + + const tags = await response.json(); + if (!tags || tags.length === 0) { + section.style.display = 'none'; + return; + } + + allTagCounts = tags; + tagCloudShowAll = false; + renderTagCloud(); + empty.style.display = 'none'; + section.style.display = 'block'; + } catch (error) { + console.warn('Could not load tag cloud:', error); + section.style.display = 'none'; + } +} + +function renderTagCloud() { + const container = document.getElementById('tagCloud'); + const visible = tagCloudShowAll ? allTagCounts : allTagCounts.slice(0, tagCloudMaxTags); + let html = visible.map(renderTagCloudItem).join(''); + if (allTagCounts.length > tagCloudMaxTags) { + html += `${tagCloudShowAll ? 'Show fewer' : `Show all ${allTagCounts.length} tags`}`; + } + container.innerHTML = html; +} + +function toggleTagCloud() { + tagCloudShowAll = !tagCloudShowAll; + renderTagCloud(); +} + +function renderTagCloudItem(tagCount) { + const fontSize = 0.8 + Math.min(0.8, (tagCount.count - 1) * 0.15); + const href = `/memories?tag=${encodeURIComponent(tagCount.tag)}`; + return `${escapeHtml(tagCount.tag)}`; } function renderProjectCard(project) { diff --git a/src/Memorizer/Views/Shared/_Layout.cshtml b/src/Memorizer/Views/Shared/_Layout.cshtml index 9c5f85c..1f1c219 100644 --- a/src/Memorizer/Views/Shared/_Layout.cshtml +++ b/src/Memorizer/Views/Shared/_Layout.cshtml @@ -103,6 +103,24 @@
+ + + +
+
@@ -194,6 +212,7 @@ + @await RenderSectionAsync("Scripts", required: false) diff --git a/src/Memorizer/wwwroot/css/site.css b/src/Memorizer/wwwroot/css/site.css index b9f4c57..434177f 100644 --- a/src/Memorizer/wwwroot/css/site.css +++ b/src/Memorizer/wwwroot/css/site.css @@ -92,8 +92,8 @@ } .card-header { - background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%); - color: var(--text-on-brand); + background-color: var(--surface-card-alt); + color: var(--text-primary); border-radius: 0.75rem 0.75rem 0 0 !important; } @@ -593,3 +593,42 @@ footer a:hover { color: #93c5fd; border-color: #2d5a8e; } + +/* ================================================================= + TAG CLOUD + Uses theme variables so light and dark modes are both covered. + ================================================================= */ +.tag-cloud { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.9rem; + line-height: 1.6; +} + +.tag-cloud-item { + color: var(--md-link); + text-decoration: none; + font-weight: 500; + transition: color 0.15s ease, opacity 0.15s ease; +} + +.tag-cloud-item:hover, +.tag-cloud-item:focus { + color: var(--brand-primary-hover); + text-decoration: underline; +} + +.tag-cloud-toggle { + display: inline-block; + margin-left: 0.5rem; + color: var(--text-muted); + font-size: 0.8rem; + text-decoration: none; +} + +.tag-cloud-toggle:hover, +.tag-cloud-toggle:focus { + color: var(--md-link); + text-decoration: underline; +} diff --git a/src/Memorizer/wwwroot/css/workspace-tree.css b/src/Memorizer/wwwroot/css/workspace-tree.css index 2d43523..841e3a7 100644 --- a/src/Memorizer/wwwroot/css/workspace-tree.css +++ b/src/Memorizer/wwwroot/css/workspace-tree.css @@ -466,3 +466,139 @@ [data-theme="dark"] .workspace-tree-breadcrumbs { background-color: rgba(255, 255, 255, 0.03); } + +/* ================================================================= + SIDEBAR TAGS SECTION + Collapsible global tag navigation in the sidebar. + ================================================================= */ +.sidebar-tags-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 1rem; + margin-bottom: 0.25rem; + background: transparent; + border: none; + color: var(--text-on-brand-muted); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + text-align: left; + opacity: 0.8; + transition: all 0.15s ease; +} + +.sidebar-tags-header:hover { + color: var(--text-on-brand); + opacity: 1; +} + +.sidebar-tags-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + flex-shrink: 0; + font-size: 0.65rem; + opacity: 0.7; + transition: transform 0.2s ease; +} + +.sidebar-tags-toggle.expanded { + transform: rotate(90deg); +} + +.sidebar-tags-count { + font-size: 0.7rem; + background-color: rgba(255, 255, 255, 0.2); + color: var(--text-on-brand-muted); + padding: 0.125rem 0.375rem; + border-radius: 0.75rem; + min-width: 1.25rem; + text-align: center; + flex-shrink: 0; + margin-left: auto; +} + +.sidebar-tags-body { + display: none; + padding: 0.25rem 0.75rem 0.75rem; +} + +.sidebar-tags-body.expanded { + display: block; +} + +.sidebar-tags-pills { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.sidebar-tags-pill { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.2rem 0.625rem; + border-radius: 1rem; + background-color: rgba(255, 255, 255, 0.15); + color: var(--text-on-brand); + text-decoration: none; + font-size: 0.75rem; + white-space: nowrap; + transition: all 0.2s ease; +} + +.sidebar-tags-pill:hover { + background-color: var(--surface-overlay); + color: var(--text-on-brand); +} + +.sidebar-tags-pill-count { + font-size: 0.65rem; + line-height: 1; + background-color: rgba(255, 255, 255, 0.25); + padding: 0.125rem 0.375rem; + border-radius: 0.75rem; + min-width: 1rem; + text-align: center; +} + +.sidebar-tags-more { + display: block; + padding: 0.375rem 0.75rem; + color: var(--text-on-brand-muted); + text-decoration: none; + font-size: 0.75rem; + font-style: italic; + opacity: 0.8; +} + +.sidebar-tags-more:hover { + color: var(--text-on-brand); + opacity: 1; +} + +.sidebar-tags-loading, +.sidebar-tags-empty { + color: var(--text-on-brand-muted); + font-size: 0.8rem; + padding: 0.5rem 0.75rem; + opacity: 0.7; +} + +.sidebar-tags-loading { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.sidebar-tags-loading .spinner-border { + width: 0.875rem; + height: 0.875rem; + border-width: 0.125rem; +} diff --git a/src/Memorizer/wwwroot/js/sidebar-tags.js b/src/Memorizer/wwwroot/js/sidebar-tags.js new file mode 100644 index 0000000..f6a63af --- /dev/null +++ b/src/Memorizer/wwwroot/js/sidebar-tags.js @@ -0,0 +1,111 @@ +/** + * Sidebar Tags Module + * Loads global tag counts and renders a collapsible tag navigation section in the sidebar. + */ +const SidebarTags = (function() { + const STORAGE_KEY = 'memorizer-sidebar-tags-expanded'; + const MAX_TAGS = 30; + let container = null; + let isInitialized = false; + let tagCounts = null; + + function getExpanded() { + try { + return localStorage.getItem(STORAGE_KEY) === 'true'; + } catch (e) { + return false; + } + } + + function setExpanded(expanded) { + try { + localStorage.setItem(STORAGE_KEY, expanded ? 'true' : 'false'); + } catch (e) { + // ignore storage errors + } + } + + function applyExpanded(expanded) { + container?.querySelector('.sidebar-tags-toggle')?.classList.toggle('expanded', expanded); + container?.querySelector('.sidebar-tags-body')?.classList.toggle('expanded', expanded); + container?.querySelector('.sidebar-tags-header')?.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + } + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function render() { + const body = container.querySelector('.sidebar-tags-body'); + const total = container.querySelector('#sidebarTagsTotal'); + if (!body) return; + + if (!tagCounts || tagCounts.length === 0) { + body.innerHTML = ''; + if (total) total.style.display = 'none'; + return; + } + + if (total) { + total.textContent = tagCounts.length; + total.style.display = 'inline-block'; + } + + const pills = tagCounts.slice(0, MAX_TAGS).map(t => + ` + ${escapeHtml(t.tag)} ${t.count} + ` + ).join(''); + + const more = tagCounts.length > MAX_TAGS + ? `View all ${tagCounts.length} tags` + : ''; + + body.innerHTML = `${more}`; + } + + async function load() { + try { + const response = await fetch('/api/memory/tags/cloud'); + if (!response.ok) throw new Error(`Failed to load tags: ${response.status}`); + tagCounts = await response.json(); + render(); + } catch (e) { + console.warn('Failed to load sidebar tags:', e); + const body = container?.querySelector('.sidebar-tags-body'); + if (body) body.innerHTML = ''; + } + } + + function toggle() { + const expanded = !getExpanded(); + setExpanded(expanded); + applyExpanded(expanded); + } + + function init() { + if (isInitialized) return; + container = document.getElementById('sidebar-tags-container'); + if (!container) { + console.warn('Sidebar tags container not found'); + return; + } + isInitialized = true; + applyExpanded(getExpanded()); + load(); + } + + return { + init: init, + toggle: toggle, + load: load + }; +})(); + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', function() { + SidebarTags.init(); +});