Skip to content
159 changes: 159 additions & 0 deletions src/Memorizer.IntegrationTests/KnowledgeGraphIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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;

/// <summary>
/// Integration tests for the knowledge graph service (IGraphService).
/// </summary>
[Collection(nameof(IntegrationTestCollection))]
public class KnowledgeGraphIntegrationTests : IDisposable
{
private readonly IntegrationTestFixture _fixture;
private readonly ITestOutputHelper _output;
private readonly IServiceProvider _services;

public void Dispose()
{
(_services as IDisposable)?.Dispose();
}

public KnowledgeGraphIntegrationTests(IntegrationTestFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
_services = CreateServices();
}

private IServiceProvider CreateServices()
{
var services = new ServiceCollection();

services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Storage"] = _fixture.PostgresConnectionString,
["Embeddings:ApiUrl"] = _fixture.OllamaApiUrl,
["Embeddings:Model"] = "all-minilm",
["Embeddings:Timeout"] = TimeSpan.FromMinutes(1).ToString()
})
.Build());

services.AddHttpClient<IEmbeddingService, EmbeddingService>(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 GetKnowledgeGraph_ReturnsAllNodesAndEdges_WithNoScope()
{
// Arrange
var storage = _services.GetRequiredService<IStorage>();
var graph = _services.GetRequiredService<IGraphService>();

var workspace = await storage.CreateWorkspaceAsync("Graph Root", "groot");
var project = await storage.CreateProjectAsync(workspace.Id, "Graph Project", "gp");

var created = new List<MemoryId>();
try
{
var m1 = await storage.StoreMemory(
"reference", "graph node one", "test", new[] { "g1" },
new Confidence(1.0), "Graph One", owner: MemoryOwner.ForWorkspace(workspace.Id));
var m2 = await storage.StoreMemory(
"reference", "graph node two", "test", new[] { "g2" },
new Confidence(1.0), "Graph Two", owner: MemoryOwner.ForProject(project.Id));
var m3 = await storage.StoreMemory(
"reference", "graph node three", "test", new[] { "g3" },
new Confidence(1.0), "Graph Three"); // unfiled
created.AddRange(new[] { m1.Id, m2.Id, m3.Id });

await storage.CreateRelationship(m1.Id, m2.Id, "related-to");
await storage.CreateRelationship(m2.Id, m3.Id, "explains");

// Act
var global = await graph.GetKnowledgeGraphAsync();

// Assert
Assert.Contains(global.Nodes, n => n.Id == m1.Id.Value);
Assert.Contains(global.Nodes, n => n.Id == m2.Id.Value);
Assert.Contains(global.Nodes, n => n.Id == m3.Id.Value);
Assert.Contains(global.Edges, e => e.From == m1.Id.Value && e.To == m2.Id.Value && e.Type == "related-to");
Assert.Contains(global.Edges, e => e.From == m2.Id.Value && e.To == m3.Id.Value && e.Type == "explains");
}
finally
{
foreach (var id in created)
await storage.Delete(id);
await storage.DeleteProjectAsync(project.Id);
await storage.DeleteWorkspaceAsync(workspace.Id);
}
}

[Fact]
public async Task GetKnowledgeGraph_WorkspaceScope_ExcludesOutOfScopeNodesAndEdges()
{
// Arrange
var storage = _services.GetRequiredService<IStorage>();
var graph = _services.GetRequiredService<IGraphService>();

var workspace = await storage.CreateWorkspaceAsync("Graph Root", "groot");
var project = await storage.CreateProjectAsync(workspace.Id, "Graph Project", "gp");

var created = new List<MemoryId>();
try
{
var m1 = await storage.StoreMemory(
"reference", "graph node one", "test", new[] { "g1" },
new Confidence(1.0), "Graph One", owner: MemoryOwner.ForWorkspace(workspace.Id));
var m2 = await storage.StoreMemory(
"reference", "graph node two", "test", new[] { "g2" },
new Confidence(1.0), "Graph Two", owner: MemoryOwner.ForProject(project.Id));
var m3 = await storage.StoreMemory(
"reference", "graph node three", "test", new[] { "g3" },
new Confidence(1.0), "Graph Three"); // unfiled, outside scope
created.AddRange(new[] { m1.Id, m2.Id, m3.Id });

await storage.CreateRelationship(m1.Id, m2.Id, "related-to");
await storage.CreateRelationship(m2.Id, m3.Id, "explains");

// Act - workspace scope covers the workspace + its projects, not unfiled
var scoped = await graph.GetKnowledgeGraphAsync(workspaceId: workspace.Id);

// Assert
Assert.Contains(scoped.Nodes, n => n.Id == m1.Id.Value);
Assert.Contains(scoped.Nodes, n => n.Id == m2.Id.Value);
Assert.DoesNotContain(scoped.Nodes, n => n.Id == m3.Id.Value);
Assert.Contains(scoped.Edges, e => e.From == m1.Id.Value && e.To == m2.Id.Value);
Assert.DoesNotContain(scoped.Edges, e => e.To == m3.Id.Value);
}
finally
{
foreach (var id in created)
await storage.Delete(id);
await storage.DeleteProjectAsync(project.Id);
await storage.DeleteWorkspaceAsync(workspace.Id);
}
}
}
36 changes: 35 additions & 1 deletion src/Memorizer/Controllers/HomeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ public class HomeController : Controller
private readonly IMemoryStatsService _statsService;
private readonly IStorage _storage;
private readonly ServerSettings _serverSettings;
private readonly IGraphService _graphService;

public HomeController(IMemoryStatsService statsService, IStorage storage, ServerSettings serverSettings)
public HomeController(IMemoryStatsService statsService, IStorage storage, ServerSettings serverSettings, IGraphService graphService)
{
_statsService = statsService;
_storage = storage;
_serverSettings = serverSettings;
_graphService = graphService;
}

/// <summary>
Expand Down Expand Up @@ -83,6 +85,38 @@ public async Task<IActionResult> Stats()
return View(stats);
}

/// <summary>
/// Knowledge graph page - visualizes memories and their relationships.
/// Optional ?workspaceId= / ?projectId= query params scope the graph.
/// </summary>
[HttpGet]
[Route("graph")]
public IActionResult Graph()
{
return View();
}

/// <summary>
/// API endpoint returning the knowledge graph (nodes + edges), optionally scoped
/// to a workspace subtree or a single project.
/// </summary>
[HttpGet]
[Route("api/graph")]
public async Task<IActionResult> GetGraph(
[FromQuery] Guid? workspaceId = null,
[FromQuery] Guid? projectId = null,
CancellationToken cancellationToken = default)
{
if (projectId.HasValue && workspaceId.HasValue)
return BadRequest(new { message = "projectId and workspaceId are mutually exclusive graph scopes." });

WorkspaceId? typedWorkspaceId = workspaceId.HasValue ? new WorkspaceId(workspaceId.Value) : null;
ProjectId? typedProjectId = projectId.HasValue ? new ProjectId(projectId.Value) : null;

var graph = await _graphService.GetKnowledgeGraphAsync(typedWorkspaceId, typedProjectId, cancellationToken);
return Json(graph);
}

/// <summary>
/// MCP configuration page - shows configuration UI
/// </summary>
Expand Down
24 changes: 24 additions & 0 deletions src/Memorizer/Models/GraphData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Memorizer.Models;

/// <summary>
/// Full snapshot of the memory knowledge graph: nodes (memories) and edges (relationships).
/// </summary>
public class GraphData
{
public List<GraphNode> Nodes { get; set; } = new();
public List<GraphEdge> Edges { get; set; } = new();
}

public class GraphNode
{
public Guid Id { get; init; }
public string Title { get; init; } = "Untitled";
public string Type { get; init; } = "";
}

public class GraphEdge
{
public Guid From { get; init; }
public Guid To { get; init; }
public string Type { get; init; } = "";
}
115 changes: 115 additions & 0 deletions src/Memorizer/Services/GraphService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using Memorizer.Models;
using Memorizer.Models.Enums;
using Memorizer.Models.ValueTypes;
using Npgsql;
using Registrator.Net;

namespace Memorizer.Services;

/// <summary>
/// Provides the memory knowledge graph (nodes + relationship edges), optionally
/// scoped to a workspace subtree or a single project.
/// </summary>
[AutoRegisterInterfaces(ServiceLifetime.Scoped)]
public class GraphService : IGraphService
{
private readonly NpgsqlDataSource _dataSource;

public GraphService(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}

public async Task<GraphData> GetKnowledgeGraphAsync(
WorkspaceId? workspaceId = null,
ProjectId? projectId = null,
CancellationToken cancellationToken = default)
{
if (workspaceId.HasValue && projectId.HasValue)
throw new ArgumentException("workspaceId and projectId are mutually exclusive graph scopes.");

await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);

// Owner scope for nodes. For a workspace this spans the entire subtree
// (nested workspaces + all projects within them) via a recursive CTE.
string ownerClause = "";
var parameters = new List<(string Name, object Value)>();
if (projectId.HasValue)
{
ownerClause = " AND owner_type = @projectType AND owner_id = @projectId";
parameters.Add(("@projectType", (short)OwnerTypeEnum.Project));
parameters.Add(("@projectId", projectId.Value.Value));
}
else if (workspaceId.HasValue)
{
ownerClause = @"
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)))
)";
parameters.Add(("@workspaceType", (short)OwnerTypeEnum.Workspace));
parameters.Add(("@projectType", (short)OwnerTypeEnum.Project));
parameters.Add(("@root", workspaceId.Value.Value));
}

string cte = workspaceId.HasValue
? @"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
) "
: "";

// Nodes: all non-archived memories within scope.
string nodeSql = $"{cte}SELECT id, COALESCE(NULLIF(title, ''), 'Untitled'), type_legacy FROM memories WHERE archetype IN (0, 1){ownerClause}";
var nodes = new List<GraphNode>();
await using (var nodeCmd = new NpgsqlCommand(nodeSql, connection))
{
foreach (var (name, value) in parameters)
nodeCmd.Parameters.AddWithValue(name, value);

await using var reader = await nodeCmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
nodes.Add(new GraphNode
{
Id = reader.GetGuid(0),
Title = reader.GetString(1),
Type = reader.IsDBNull(2) ? "" : reader.GetString(2)
});
}
}

if (nodes.Count == 0)
return new GraphData();

// Edges: relationships where both endpoints are non-archived.
const string edgeSql = @"
SELECT r.from_memory_id, r.to_memory_id, r.type
FROM memory_relationships r
JOIN memories f ON f.id = r.from_memory_id
JOIN memories t ON t.id = r.to_memory_id
WHERE f.archetype IN (0, 1) AND t.archetype IN (0, 1)";

var edges = new List<GraphEdge>();
await using (var edgeCmd = new NpgsqlCommand(edgeSql, connection))
await using (var reader = await edgeCmd.ExecuteReaderAsync(cancellationToken))
{
while (await reader.ReadAsync(cancellationToken))
{
edges.Add(new GraphEdge
{
From = reader.GetGuid(0),
To = reader.GetGuid(1),
Type = reader.GetString(2)
});
}
}

// Filter edges to those whose endpoints are in scope.
var scopedNodeIds = nodes.Select(n => n.Id).ToHashSet();
var scopedEdges = edges.Where(e => scopedNodeIds.Contains(e.From) && scopedNodeIds.Contains(e.To)).ToList();

return new GraphData { Nodes = nodes, Edges = scopedEdges };
}
}
22 changes: 22 additions & 0 deletions src/Memorizer/Services/IGraphService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Memorizer.Models;
using Memorizer.Models.ValueTypes;

namespace Memorizer.Services;

/// <summary>
/// Provides the memory knowledge graph (nodes + relationship edges), optionally
/// scoped to a workspace subtree or a single project.
/// </summary>
public interface IGraphService
{
/// <summary>
/// Gets all non-archived memories as graph nodes and their relationships as edges.
/// </summary>
/// <param name="workspaceId">When set, restricts the graph to the workspace subtree
/// (direct memories + projects + nested workspaces). Mutually exclusive with projectId.</param>
/// <param name="projectId">When set, restricts the graph to memories owned by this project.</param>
Task<GraphData> GetKnowledgeGraphAsync(
WorkspaceId? workspaceId = null,
ProjectId? projectId = null,
CancellationToken cancellationToken = default);
}
Loading
Loading