M6: working memory + the PO→QA trigger + analytics — V1 complete
Working memory (Memory module's first real code): - MemoryEntry (schema "memory", vector(384), InitialMemory migration); TeamMemory implements the SharedKernel ITeamMemory seam (embed-and-store on write, cosine recall on read); GET /api/memory/search. HashingTextEmbedder promoted to SharedKernel (pure, deterministic; swapped for ONNX/BYOK embedders later behind ITextEmbedder). - Written on approval: Governance's approve stores an Approval/Correction entry per decision. - Read at assembly: the executor recalls the team's top-3 relevant entries; the prompt gains a "# Team memory" section (treated as data, not instructions). The single V1 event trigger: - IAgentDispatcher (SharedKernel) implemented by Assembler's AgentRunDispatcher (shared by the API and triggers). OrgBoard's QaHandoffTrigger: a task hitting done creates a QA task (provenance parent, assigned to the QA agent) and dispatches a run for the team's QA AI seat. Guardrails: Test/Review tasks never re-trigger (no self-cascade) and a task hands off at most once. Audited as handoff.triggered. Analytics — the V1 verdict view: - IBoardStats (SharedKernel) implemented by OrgBoard; GET /api/governance/analytics returns approval rate, avg edit distance, per-agent metrics + edit-distance trend, tasks done. - UI: /analytics — stat cards, per-agent table, recharts edit-distance trend per agent. Verified: build green; ArchitectureTests 8/8; IntegrationTests 42/42 incl. the M6 acceptance end to end — a dev marks a story done → Quill wakes via the handoff (QA task with provenance, assigned to the agent) → drafts a test plan that waits in review → approve records the second agent's edit distance → analytics show approval rate 100%, avg edit distance > 0, and trends for BOTH Aria and Quill; memory written on Aria's corrected approval is recalled into her next prompt; the guardrails hold. Client build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
using Pgvector;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.Memory.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// One unit of team working memory: a decision, approval, or correction. Embedded for pgvector
|
||||
/// similarity so the assembler can recall the most relevant entries at prompt time.
|
||||
/// </summary>
|
||||
internal sealed class MemoryEntry : Entity
|
||||
{
|
||||
public Guid TeamId { get; private set; }
|
||||
public MemoryKind Kind { get; private set; }
|
||||
public string Content { get; private set; } = null!;
|
||||
public Vector Embedding { get; private set; } = null!;
|
||||
public Guid? SourceReviewItemId { get; private set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
|
||||
private MemoryEntry()
|
||||
{
|
||||
}
|
||||
|
||||
public MemoryEntry(
|
||||
Guid teamId,
|
||||
MemoryKind kind,
|
||||
string content,
|
||||
Vector embedding,
|
||||
Guid? sourceReviewItemId,
|
||||
DateTimeOffset createdAtUtc)
|
||||
{
|
||||
TeamId = teamId;
|
||||
Kind = kind;
|
||||
Content = content;
|
||||
Embedding = embedding;
|
||||
SourceReviewItemId = sourceReviewItemId;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,52 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using TeamUp.Modules.Memory.Persistence;
|
||||
using TeamUp.Modules.Memory.Services;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
using TeamUp.SharedKernel.Persistence;
|
||||
|
||||
namespace TeamUp.Modules.Memory;
|
||||
|
||||
/// <summary>Team-scoped working memory: read at assembly, written on approval (M6, pgvector).</summary>
|
||||
/// <summary>Team-scoped working memory: written on approval, read at assembly (pgvector, M6).</summary>
|
||||
public sealed class MemoryModule : IModule
|
||||
{
|
||||
public string Name => "memory";
|
||||
|
||||
public void Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// Skeleton: no services yet. M6 introduces this module's (internal) DbContext with a
|
||||
// pgvector-backed MemoryEntry table and the working-memory read/write services.
|
||||
var connectionString = configuration.GetConnectionString("Postgres")
|
||||
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
|
||||
|
||||
services.AddDbContext<MemoryDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
|
||||
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<MemoryDbContext>());
|
||||
services.TryAddSingleton<ITextEmbedder, HashingTextEmbedder>();
|
||||
services.AddScoped<ITeamMemory, TeamMemory>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGroup($"/api/{Name}")
|
||||
.WithTags("Memory")
|
||||
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
|
||||
var group = endpoints.MapGroup($"/api/{Name}").WithTags("Memory");
|
||||
|
||||
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
|
||||
group.MapGet("/search", Search).RequireAuthorization();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Search(
|
||||
Guid teamId, string q, int? take, ITeamMemory memory, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
return Results.BadRequest("q is required.");
|
||||
}
|
||||
|
||||
var hits = await memory.SearchAsync(teamId, q, take ?? 3, ct);
|
||||
return Results.Ok(hits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.Memory.Domain;
|
||||
using TeamUp.SharedKernel.Persistence;
|
||||
|
||||
namespace TeamUp.Modules.Memory.Persistence;
|
||||
|
||||
internal sealed class MemoryDbContext(DbContextOptions<MemoryDbContext> options)
|
||||
: DbContext(options), IModuleDbContext
|
||||
{
|
||||
public DbSet<MemoryEntry> Entries => Set<MemoryEntry>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("memory");
|
||||
|
||||
modelBuilder.Entity<MemoryEntry>(entry =>
|
||||
{
|
||||
entry.ToTable("memory_entries");
|
||||
entry.HasKey(e => e.Id);
|
||||
entry.Property(e => e.Kind).HasConversion<string>().HasMaxLength(20);
|
||||
entry.Property(e => e.Content).IsRequired();
|
||||
entry.Property(e => e.Embedding).HasColumnType("vector(384)");
|
||||
entry.HasIndex(e => new { e.TeamId, e.CreatedAtUtc });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace TeamUp.Modules.Memory.Persistence;
|
||||
|
||||
/// <summary>Design-time factory so `dotnet ef` can build the internal context (with the pgvector handler).</summary>
|
||||
internal sealed class MemoryDbContextFactory : IDesignTimeDbContextFactory<MemoryDbContext>
|
||||
{
|
||||
public MemoryDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
|
||||
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
|
||||
|
||||
var options = new DbContextOptionsBuilder<MemoryDbContext>()
|
||||
.UseNpgsql(connectionString, npgsql => npgsql.UseVector())
|
||||
.Options;
|
||||
|
||||
return new MemoryDbContext(options);
|
||||
}
|
||||
}
|
||||
Generated
+67
@@ -0,0 +1,67 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
using TeamUp.Modules.Memory.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Memory.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(MemoryDbContext))]
|
||||
[Migration("20260610082324_InitialMemory")]
|
||||
partial class InitialMemory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("memory")
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.Memory.Domain.MemoryEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Vector>("Embedding")
|
||||
.IsRequired()
|
||||
.HasColumnType("vector(384)");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid?>("SourceReviewItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId", "CreatedAtUtc");
|
||||
|
||||
b.ToTable("memory_entries", "memory");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Memory.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialMemory : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "memory");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "memory_entries",
|
||||
schema: "memory",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Kind = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
Embedding = table.Column<Vector>(type: "vector(384)", nullable: false),
|
||||
SourceReviewItemId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_memory_entries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_memory_entries_TeamId_CreatedAtUtc",
|
||||
schema: "memory",
|
||||
table: "memory_entries",
|
||||
columns: new[] { "TeamId", "CreatedAtUtc" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "memory_entries",
|
||||
schema: "memory");
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
using TeamUp.Modules.Memory.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Memory.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(MemoryDbContext))]
|
||||
partial class MemoryDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("memory")
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.Memory.Domain.MemoryEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Vector>("Embedding")
|
||||
.IsRequired()
|
||||
.HasColumnType("vector(384)");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid?>("SourceReviewItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId", "CreatedAtUtc");
|
||||
|
||||
b.ToTable("memory_entries", "memory");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Pgvector;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using TeamUp.Modules.Memory.Domain;
|
||||
using TeamUp.Modules.Memory.Persistence;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
|
||||
namespace TeamUp.Modules.Memory.Services;
|
||||
|
||||
/// <summary>Working memory: embed-and-store on write; cosine-similarity recall on read.</summary>
|
||||
internal sealed class TeamMemory(MemoryDbContext db, ITextEmbedder embedder, TimeProvider clock) : ITeamMemory
|
||||
{
|
||||
public async Task WriteAsync(
|
||||
Guid teamId,
|
||||
MemoryKind kind,
|
||||
string content,
|
||||
Guid? sourceReviewItemId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var embedding = new Vector(embedder.Embed(content));
|
||||
db.Entries.Add(new MemoryEntry(teamId, kind, content, embedding, sourceReviewItemId, clock.GetUtcNow()));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MemoryHit>> SearchAsync(
|
||||
Guid teamId,
|
||||
string query,
|
||||
int take = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var probe = new Vector(embedder.Embed(query));
|
||||
return await db.Entries
|
||||
.Where(e => e.TeamId == teamId)
|
||||
.OrderBy(e => e.Embedding.CosineDistance(probe))
|
||||
.Take(Math.Clamp(take, 1, 10))
|
||||
.Select(e => new MemoryHit(e.Id, e.Kind, e.Content, e.CreatedAtUtc))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
|
||||
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
|
||||
gains an (internal) DbContext and validators. It must never reference another module. -->
|
||||
<!-- Team-scoped working memory (M6): MemoryEntry rows with pgvector embeddings — written on
|
||||
approval (Governance via ITeamMemory), read at prompt assembly (Assembler). References
|
||||
SharedKernel only; never another module. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user