M2: skill index — SKILL.md parsing, pgvector index, query by role

Skills module (references SharedKernel only):
- Skill entity + SkillsDbContext (schema "skills") + InitialSkills migration: roles/tools/
  context as text[], risk-tagged actions and golden tests as jsonb, a nullable vector(384)
  embedding, unique (SkillKey, Version).
- SkillMarkdownParser: YAML frontmatter (YamlDotNet) + markdown body → SkillManifest.
- HashingSkillEmbedder: placeholder deterministic embedder so the pgvector path is real now;
  swapped for ONNX/BYOK embeddings at M3-M4 (384-dim to match MiniLM/bge).
- SkillIndexer: parse → hash → embed → upsert; structural publish gate (roles + >=1 golden
  test). Executing golden tests against a model + gating on edit distance lands at M4.
- Endpoints: GET /api/skills (filter by role/visibility), GET /api/skills/{key},
  POST /api/skills/index (manual/admin) — all authenticated.

Verified: build green; ArchitectureTests 8/8 (Skills references only SharedKernel);
IntegrationTests 21/21 incl. a new skill-registry flow — index a SKILL.md, it publishes,
is queryable by role (and not under others), re-index dedups, malformed is 400, catalogue
needs auth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-09 18:01:37 +03:30
parent ce5c644c7b
commit 401e3e69af
17 changed files with 1103 additions and 14 deletions
@@ -0,0 +1,98 @@
using Pgvector;
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.Skills.Domain;
/// <summary>
/// An indexed skill atom: the projection of a SKILL.md (Git is the source of truth) into a
/// queryable Postgres + pgvector row. Identified by (SkillKey, Version).
/// </summary>
internal sealed class Skill : Entity
{
public string SkillKey { get; private set; } = null!;
public string Name { get; private set; } = null!;
public string Version { get; private set; } = null!;
public string? Summary { get; private set; }
public List<string> Roles { get; private set; } = [];
public string? Inputs { get; private set; }
public string? Outputs { get; private set; }
public List<SkillAction> Actions { get; private set; } = [];
public List<string> Tools { get; private set; } = [];
public List<string> Context { get; private set; } = [];
public List<GoldenExample> GoldenTests { get; private set; } = [];
public SkillVisibility Visibility { get; private set; }
public SkillTier MinTier { get; private set; }
public SkillStatus Status { get; private set; }
public string Body { get; private set; } = null!;
public string ContentHash { get; private set; } = null!;
public string? SourceRepo { get; private set; }
public string? SourcePath { get; private set; }
public string? SourceCommit { get; private set; }
public Vector? Embedding { get; private set; }
public DateTimeOffset IndexedAtUtc { get; private set; }
public DateTimeOffset UpdatedAtUtc { get; private set; }
private Skill()
{
}
public static Skill Create(string skillKey, string version, DateTimeOffset nowUtc) =>
new() { SkillKey = skillKey, Version = version, IndexedAtUtc = nowUtc };
/// <summary>(Re)projects a parsed manifest + body onto this row. Used for both insert and update.</summary>
public void Index(
SkillManifest manifest,
string body,
string contentHash,
string? sourceRepo,
string? sourcePath,
string? sourceCommit,
Vector? embedding,
SkillStatus status,
DateTimeOffset nowUtc)
{
Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name;
Version = manifest.Version;
Summary = manifest.Summary;
Roles = manifest.Roles;
Inputs = manifest.Inputs;
Outputs = manifest.Outputs;
Actions = manifest.Actions
.Select(a => new SkillAction { Name = a.Name, Risk = ParseRisk(a.Risk), Description = a.Description })
.ToList();
Tools = manifest.Tools;
Context = manifest.Context;
GoldenTests = manifest.GoldenTests;
Visibility = ParseVisibility(manifest.Visibility);
MinTier = ParseTier(manifest.MinTier);
Status = status;
Body = body;
ContentHash = contentHash;
SourceRepo = sourceRepo;
SourcePath = sourcePath;
SourceCommit = sourceCommit;
Embedding = embedding;
UpdatedAtUtc = nowUtc;
}
private static string Normalize(string value) => value.Trim().Replace("-", string.Empty).Replace("_", string.Empty);
private static ActionRisk ParseRisk(string value) => Normalize(value).ToLowerInvariant() switch
{
"draft" => ActionRisk.Draft,
"publish" => ActionRisk.Publish,
"destructive" => ActionRisk.Destructive,
_ => ActionRisk.Read,
};
private static SkillVisibility ParseVisibility(string value) =>
Normalize(value).ToLowerInvariant() is "privatetoorg" or "private" ? SkillVisibility.PrivateToOrg : SkillVisibility.Public;
private static SkillTier ParseTier(string value) => Normalize(value).ToLowerInvariant() switch
{
"team" => SkillTier.Team,
"scale" => SkillTier.Scale,
"enterprise" => SkillTier.Enterprise,
_ => SkillTier.Free,
};
}
@@ -0,0 +1,26 @@
namespace TeamUp.Modules.Skills.Domain;
/// <summary>The YAML frontmatter of a SKILL.md (raw, as authored). Mapped onto <see cref="Skill"/>.</summary>
internal sealed class SkillManifest
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = "1.0.0";
public string? Summary { get; set; }
public List<string> Roles { get; set; } = [];
public string? Inputs { get; set; }
public string? Outputs { get; set; }
public List<ManifestAction> Actions { get; set; } = [];
public List<string> Tools { get; set; } = [];
public List<string> Context { get; set; } = [];
public string Visibility { get; set; } = "public";
public string MinTier { get; set; } = "free";
public List<GoldenExample> GoldenTests { get; set; } = [];
}
internal sealed class ManifestAction
{
public string Name { get; set; } = string.Empty;
public string Risk { get; set; } = "read";
public string? Description { get; set; }
}
@@ -0,0 +1,47 @@
namespace TeamUp.Modules.Skills.Domain;
/// <summary>public (catalogue) vs private-to-org. Enforcement is Phase 1; the field exists now.</summary>
internal enum SkillVisibility
{
Public,
PrivateToOrg,
}
internal enum SkillTier
{
Free,
Team,
Scale,
Enterprise,
}
/// <summary>Risk lives on the action; the action gate (M5) compares it to seat autonomy.</summary>
internal enum ActionRisk
{
Read,
Draft,
Publish,
Destructive,
}
/// <summary>Published only once eval (golden tests) passes — see SkillIndexer/eval harness.</summary>
internal enum SkillStatus
{
Draft,
Published,
}
/// <summary>A risk-tagged action a skill can take. Stored as JSON on the skill.</summary>
internal sealed class SkillAction
{
public string Name { get; set; } = null!;
public ActionRisk Risk { get; set; }
public string? Description { get; set; }
}
/// <summary>A golden input/expected pair the eval harness checks (edit distance) before publish.</summary>
internal sealed class GoldenExample
{
public string Input { get; set; } = null!;
public string Expected { get; set; } = null!;
}
@@ -0,0 +1,25 @@
namespace TeamUp.Modules.Skills.Endpoints;
internal sealed record ActionDto(string Name, string Risk);
internal sealed record SkillSummary(
string SkillKey,
string Name,
string Version,
string? Summary,
List<string> Roles,
string Visibility,
string MinTier,
string Status,
List<ActionDto> Actions);
internal sealed record SkillDetail(
SkillSummary Skill,
string? Inputs,
string? Outputs,
List<string> Tools,
List<string> Context,
int GoldenTestCount,
string Body);
internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit);
@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Skills.Domain;
using TeamUp.Modules.Skills.Indexing;
using TeamUp.Modules.Skills.Persistence;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Skills.Endpoints;
internal static class SkillsEndpoints
{
public static void Map(IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/skills").WithTags("Skills");
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills")));
group.MapGet("/", ListSkills).RequireAuthorization();
group.MapGet("/{key}", GetSkill).RequireAuthorization();
group.MapPost("/index", IndexSkill).RequireAuthorization();
}
private static async Task<IResult> ListSkills(
string? role, string? visibility, SkillsDbContext db, CancellationToken ct)
{
var query = db.Skills.AsQueryable();
if (!string.IsNullOrWhiteSpace(role))
{
query = query.Where(s => s.Roles.Contains(role));
}
if (Enum.TryParse<SkillVisibility>(visibility, ignoreCase: true, out var vis))
{
query = query.Where(s => s.Visibility == vis);
}
var skills = await query
.OrderBy(s => s.SkillKey)
.ThenByDescending(s => s.Version)
.ToListAsync(ct);
return Results.Ok(skills.Select(ToSummary).ToList());
}
private static async Task<IResult> GetSkill(string key, SkillsDbContext db, CancellationToken ct)
{
var versions = await db.Skills
.Where(s => s.SkillKey == key)
.OrderByDescending(s => s.Version)
.ToListAsync(ct);
return versions.Count == 0
? Results.NotFound()
: Results.Ok(versions.Select(ToDetail).ToList());
}
private static async Task<IResult> IndexSkill(IndexRequest request, SkillIndexer indexer, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Content))
{
return Results.BadRequest("content is required.");
}
try
{
var skill = await indexer.IndexAsync(
request.Content, request.SourceRepo, request.SourcePath, request.SourceCommit, ct);
return Results.Ok(ToDetail(skill));
}
catch (FormatException ex)
{
return Results.BadRequest(ex.Message);
}
}
private static SkillSummary ToSummary(Skill skill) => new(
skill.SkillKey,
skill.Name,
skill.Version,
skill.Summary,
skill.Roles,
skill.Visibility.ToString(),
skill.MinTier.ToString(),
skill.Status.ToString(),
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString())).ToList());
private static SkillDetail ToDetail(Skill skill) => new(
ToSummary(skill),
skill.Inputs,
skill.Outputs,
skill.Tools,
skill.Context,
skill.GoldenTests.Count,
skill.Body);
}
@@ -0,0 +1,67 @@
namespace TeamUp.Modules.Skills.Indexing;
internal interface ISkillEmbedder
{
int Dimensions { get; }
float[] Embed(string text);
}
/// <summary>
/// Placeholder deterministic embedder (L2-normalized hashed bag-of-tokens) so the pgvector index +
/// similarity queries are REAL in M2. Replaced by ONNX (air-gapped) / BYOK embeddings in M3M4;
/// the 384 dimension matches the intended MiniLM/bge models so the column survives the swap.
/// </summary>
internal sealed class HashingSkillEmbedder : ISkillEmbedder
{
private static readonly char[] Separators =
[' ', '\n', '\t', ',', '.', ':', ';', '(', ')', '[', ']', '{', '}', '/', '\\', '"', '\'', '#', '-', '_', '*', '`', '!', '?'];
public int Dimensions => 384;
public float[] Embed(string text)
{
var vector = new float[Dimensions];
if (string.IsNullOrWhiteSpace(text))
{
return vector;
}
foreach (var token in text.ToLowerInvariant().Split(Separators, StringSplitOptions.RemoveEmptyEntries))
{
vector[Hash(token) % Dimensions] += 1f;
}
var norm = 0f;
foreach (var value in vector)
{
norm += value * value;
}
norm = MathF.Sqrt(norm);
if (norm > 0f)
{
for (var i = 0; i < vector.Length; i++)
{
vector[i] /= norm;
}
}
return vector;
}
private static uint Hash(string token)
{
unchecked
{
var hash = 2166136261u;
foreach (var c in token)
{
hash ^= c;
hash *= 16777619u;
}
return hash;
}
}
}
@@ -0,0 +1,51 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Pgvector;
using TeamUp.Modules.Skills.Domain;
using TeamUp.Modules.Skills.Parsing;
using TeamUp.Modules.Skills.Persistence;
namespace TeamUp.Modules.Skills.Indexing;
/// <summary>Parses a SKILL.md, computes its embedding, and upserts the Skill row (by key+version).</summary>
internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder, TimeProvider clock)
{
public async Task<Skill> IndexAsync(
string content,
string? sourceRepo,
string? sourcePath,
string? sourceCommit,
CancellationToken cancellationToken = default)
{
var parsed = SkillMarkdownParser.Parse(content);
var manifest = parsed.Manifest;
var now = clock.GetUtcNow();
var contentHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{parsed.Body}";
var embedding = new Vector(embedder.Embed(embeddingText));
// M2 publish gate (structural): a skill is published only if it declares roles and carries
// at least one well-formed golden test. Executing the golden tests against a model — and
// gating on edit distance — lands in M4 when the assembler/runtime exists.
var status = manifest.Roles.Count > 0 && manifest.GoldenTests.Count > 0
? SkillStatus.Published
: SkillStatus.Draft;
var skill = await db.Skills
.FirstOrDefaultAsync(s => s.SkillKey == manifest.Id && s.Version == manifest.Version, cancellationToken);
var isNew = skill is null;
skill ??= Skill.Create(manifest.Id, manifest.Version, now);
skill.Index(manifest, parsed.Body, contentHash, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
if (isNew)
{
db.Skills.Add(skill);
}
await db.SaveChangesAsync(cancellationToken);
return skill;
}
}
@@ -0,0 +1,45 @@
using TeamUp.Modules.Skills.Domain;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace TeamUp.Modules.Skills.Parsing;
internal sealed record ParsedSkill(SkillManifest Manifest, string Body);
/// <summary>Splits a SKILL.md into its YAML frontmatter (between '---' fences) and markdown body.</summary>
internal static class SkillMarkdownParser
{
private static readonly IDeserializer Yaml = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
public static ParsedSkill Parse(string content)
{
var text = content.Replace("\r\n", "\n").Replace("\r", "\n").TrimStart();
if (!text.StartsWith("---\n", StringComparison.Ordinal))
{
throw new FormatException("SKILL.md must begin with a YAML frontmatter block delimited by '---'.");
}
var rest = text[4..];
var closeIndex = rest.IndexOf("\n---", StringComparison.Ordinal);
if (closeIndex < 0)
{
throw new FormatException("SKILL.md frontmatter is not closed with '---'.");
}
var frontmatter = rest[..closeIndex];
var afterClose = rest[(closeIndex + 1)..];
var newline = afterClose.IndexOf('\n');
var body = newline < 0 ? string.Empty : afterClose[(newline + 1)..].Trim();
var manifest = Yaml.Deserialize<SkillManifest>(frontmatter) ?? new SkillManifest();
if (string.IsNullOrWhiteSpace(manifest.Id))
{
throw new FormatException("SKILL.md frontmatter must include an 'id'.");
}
return new ParsedSkill(manifest, body);
}
}
@@ -0,0 +1,188 @@
// <auto-generated />
using System;
using System.Collections.Generic;
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.Skills.Persistence;
#nullable disable
namespace TeamUp.Modules.Skills.Persistence.Migrations
{
[DbContext(typeof(SkillsDbContext))]
[Migration("20260609141931_InitialSkills")]
partial class InitialSkills
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skills")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.PrimitiveCollection<List<string>>("Context")
.IsRequired()
.HasColumnType("text[]");
b.Property<Vector>("Embedding")
.HasColumnType("vector(384)");
b.Property<DateTimeOffset>("IndexedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Inputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("MinTier")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Outputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("SkillKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("SourceCommit")
.HasColumnType("text");
b.Property<string>("SourcePath")
.HasColumnType("text");
b.Property<string>("SourceRepo")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.PrimitiveCollection<List<string>>("Tools")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Visibility")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("Status");
b.HasIndex("SkillKey", "Version")
.IsUnique();
b.ToTable("skills", "skills");
});
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.OwnsMany("TeamUp.Modules.Skills.Domain.GoldenExample", "GoldenTests", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Expected")
.IsRequired();
b1.Property<string>("Input")
.IsRequired();
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("GoldenTests")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.OwnsMany("TeamUp.Modules.Skills.Domain.SkillAction", "Actions", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Description");
b1.Property<string>("Name")
.IsRequired();
b1.Property<int>("Risk");
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("Actions")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.Navigation("Actions");
b.Navigation("GoldenTests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using Pgvector;
#nullable disable
namespace TeamUp.Modules.Skills.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialSkills : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "skills");
migrationBuilder.CreateTable(
name: "skills",
schema: "skills",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SkillKey = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Version = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Summary = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
Roles = table.Column<List<string>>(type: "text[]", nullable: false),
Inputs = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
Outputs = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
Tools = table.Column<List<string>>(type: "text[]", nullable: false),
Context = table.Column<List<string>>(type: "text[]", nullable: false),
Visibility = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
MinTier = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Body = table.Column<string>(type: "text", nullable: false),
ContentHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SourceRepo = table.Column<string>(type: "text", nullable: true),
SourcePath = table.Column<string>(type: "text", nullable: true),
SourceCommit = table.Column<string>(type: "text", nullable: true),
Embedding = table.Column<Vector>(type: "vector(384)", nullable: true),
IndexedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Actions = table.Column<string>(type: "jsonb", nullable: true),
GoldenTests = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_skills", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_skills_SkillKey_Version",
schema: "skills",
table: "skills",
columns: new[] { "SkillKey", "Version" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_skills_Status",
schema: "skills",
table: "skills",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "skills",
schema: "skills");
}
}
}
@@ -0,0 +1,185 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
using TeamUp.Modules.Skills.Persistence;
#nullable disable
namespace TeamUp.Modules.Skills.Persistence.Migrations
{
[DbContext(typeof(SkillsDbContext))]
partial class SkillsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skills")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.PrimitiveCollection<List<string>>("Context")
.IsRequired()
.HasColumnType("text[]");
b.Property<Vector>("Embedding")
.HasColumnType("vector(384)");
b.Property<DateTimeOffset>("IndexedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Inputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("MinTier")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Outputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("SkillKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("SourceCommit")
.HasColumnType("text");
b.Property<string>("SourcePath")
.HasColumnType("text");
b.Property<string>("SourceRepo")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.PrimitiveCollection<List<string>>("Tools")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Visibility")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("Status");
b.HasIndex("SkillKey", "Version")
.IsUnique();
b.ToTable("skills", "skills");
});
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.OwnsMany("TeamUp.Modules.Skills.Domain.GoldenExample", "GoldenTests", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Expected")
.IsRequired();
b1.Property<string>("Input")
.IsRequired();
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("GoldenTests")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.OwnsMany("TeamUp.Modules.Skills.Domain.SkillAction", "Actions", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Description");
b1.Property<string>("Name")
.IsRequired();
b1.Property<int>("Risk");
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("Actions")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.Navigation("Actions");
b.Navigation("GoldenTests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Skills.Domain;
using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Skills.Persistence;
internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
: DbContext(options), IModuleDbContext
{
public DbSet<Skill> Skills => Set<Skill>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("skills");
modelBuilder.Entity<Skill>(skill =>
{
skill.ToTable("skills");
skill.HasKey(s => s.Id);
skill.Property(s => s.SkillKey).HasMaxLength(128).IsRequired();
skill.Property(s => s.Name).HasMaxLength(200).IsRequired();
skill.Property(s => s.Version).HasMaxLength(32).IsRequired();
skill.Property(s => s.Summary).HasMaxLength(1000);
skill.Property(s => s.Inputs).HasMaxLength(2000);
skill.Property(s => s.Outputs).HasMaxLength(2000);
skill.Property(s => s.Visibility).HasConversion<string>().HasMaxLength(20);
skill.Property(s => s.MinTier).HasConversion<string>().HasMaxLength(20);
skill.Property(s => s.Status).HasConversion<string>().HasMaxLength(20);
skill.Property(s => s.ContentHash).HasMaxLength(64);
skill.Property(s => s.Embedding).HasColumnType("vector(384)");
// Risk-tagged actions and golden tests as jsonb.
skill.OwnsMany(s => s.Actions, owned => owned.ToJson());
skill.OwnsMany(s => s.GoldenTests, owned => owned.ToJson());
skill.HasIndex(s => new { s.SkillKey, s.Version }).IsUnique();
skill.HasIndex(s => s.Status);
});
}
}
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace TeamUp.Modules.Skills.Persistence;
/// <summary>Design-time factory so `dotnet ef` can build the internal context (with the pgvector handler).</summary>
internal sealed class SkillsDbContextFactory : IDesignTimeDbContextFactory<SkillsDbContext>
{
public SkillsDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
var options = new DbContextOptionsBuilder<SkillsDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.UseVector())
.Options;
return new SkillsDbContext(options);
}
}
@@ -1,27 +1,32 @@
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.Skills.Endpoints;
using TeamUp.Modules.Skills.Indexing;
using TeamUp.Modules.Skills.Persistence;
using TeamUp.SharedKernel.Modularity;
using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Skills;
/// <summary>Git-sourced skill registry: sync, the queryable atom index, versioning, evals (M2).</summary>
/// <summary>Git-sourced skill registry: the queryable atom index, versioning, the eval harness (M2).</summary>
public sealed class SkillsModule : IModule
{
public string Name => "skills";
public void Register(IServiceCollection services, IConfiguration configuration)
{
// Skeleton: no services yet. M2 introduces this module's (internal) DbContext,
// FluentValidation validators, and domain services here.
var connectionString = configuration.GetConnectionString("Postgres")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
services.AddDbContext<SkillsDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<SkillsDbContext>());
services.AddSingleton<ISkillEmbedder, HashingSkillEmbedder>();
services.AddScoped<SkillIndexer>();
services.TryAddSingleton(TimeProvider.System);
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGroup($"/api/{Name}")
.WithTags("Skills")
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
}
public void MapEndpoints(IEndpointRouteBuilder endpoints) => SkillsEndpoints.Map(endpoints);
}
@@ -1,10 +1,19 @@
<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. -->
<!-- Git-sourced skill registry: SKILL.md (YAML frontmatter) parsed into a queryable Postgres +
pgvector index, with an eval/golden harness. References SharedKernel only; reads Git through
the SharedKernel IGitProvider seam (implemented by Integrations). -->
<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" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
</Project>