diff --git a/Directory.Packages.props b/Directory.Packages.props index 3fb601f..cbee764 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,7 @@ + diff --git a/src/Modules/TeamUp.Modules.Skills/Domain/Skill.cs b/src/Modules/TeamUp.Modules.Skills/Domain/Skill.cs new file mode 100644 index 0000000..53124ea --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Domain/Skill.cs @@ -0,0 +1,98 @@ +using Pgvector; +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.Skills.Domain; + +/// +/// 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). +/// +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 Roles { get; private set; } = []; + public string? Inputs { get; private set; } + public string? Outputs { get; private set; } + public List Actions { get; private set; } = []; + public List Tools { get; private set; } = []; + public List Context { get; private set; } = []; + public List 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 }; + + /// (Re)projects a parsed manifest + body onto this row. Used for both insert and update. + 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, + }; +} diff --git a/src/Modules/TeamUp.Modules.Skills/Domain/SkillManifest.cs b/src/Modules/TeamUp.Modules.Skills/Domain/SkillManifest.cs new file mode 100644 index 0000000..2990bf3 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Domain/SkillManifest.cs @@ -0,0 +1,26 @@ +namespace TeamUp.Modules.Skills.Domain; + +/// The YAML frontmatter of a SKILL.md (raw, as authored). Mapped onto . +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 Roles { get; set; } = []; + public string? Inputs { get; set; } + public string? Outputs { get; set; } + public List Actions { get; set; } = []; + public List Tools { get; set; } = []; + public List Context { get; set; } = []; + public string Visibility { get; set; } = "public"; + public string MinTier { get; set; } = "free"; + public List 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; } +} diff --git a/src/Modules/TeamUp.Modules.Skills/Domain/SkillTypes.cs b/src/Modules/TeamUp.Modules.Skills/Domain/SkillTypes.cs new file mode 100644 index 0000000..d02f80e --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Domain/SkillTypes.cs @@ -0,0 +1,47 @@ +namespace TeamUp.Modules.Skills.Domain; + +/// public (catalogue) vs private-to-org. Enforcement is Phase 1; the field exists now. +internal enum SkillVisibility +{ + Public, + PrivateToOrg, +} + +internal enum SkillTier +{ + Free, + Team, + Scale, + Enterprise, +} + +/// Risk lives on the action; the action gate (M5) compares it to seat autonomy. +internal enum ActionRisk +{ + Read, + Draft, + Publish, + Destructive, +} + +/// Published only once eval (golden tests) passes — see SkillIndexer/eval harness. +internal enum SkillStatus +{ + Draft, + Published, +} + +/// A risk-tagged action a skill can take. Stored as JSON on the skill. +internal sealed class SkillAction +{ + public string Name { get; set; } = null!; + public ActionRisk Risk { get; set; } + public string? Description { get; set; } +} + +/// A golden input/expected pair the eval harness checks (edit distance) before publish. +internal sealed class GoldenExample +{ + public string Input { get; set; } = null!; + public string Expected { get; set; } = null!; +} diff --git a/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsDtos.cs b/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsDtos.cs new file mode 100644 index 0000000..c76f617 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsDtos.cs @@ -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 Roles, + string Visibility, + string MinTier, + string Status, + List Actions); + +internal sealed record SkillDetail( + SkillSummary Skill, + string? Inputs, + string? Outputs, + List Tools, + List Context, + int GoldenTestCount, + string Body); + +internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit); diff --git a/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsEndpoints.cs b/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsEndpoints.cs new file mode 100644 index 0000000..9fdb7ca --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsEndpoints.cs @@ -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 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(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 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 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); +} diff --git a/src/Modules/TeamUp.Modules.Skills/Indexing/SkillEmbedder.cs b/src/Modules/TeamUp.Modules.Skills/Indexing/SkillEmbedder.cs new file mode 100644 index 0000000..9b6c3d7 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Indexing/SkillEmbedder.cs @@ -0,0 +1,67 @@ +namespace TeamUp.Modules.Skills.Indexing; + +internal interface ISkillEmbedder +{ + int Dimensions { get; } + + float[] Embed(string text); +} + +/// +/// 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 M3–M4; +/// the 384 dimension matches the intended MiniLM/bge models so the column survives the swap. +/// +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; + } + } +} diff --git a/src/Modules/TeamUp.Modules.Skills/Indexing/SkillIndexer.cs b/src/Modules/TeamUp.Modules.Skills/Indexing/SkillIndexer.cs new file mode 100644 index 0000000..f80c6be --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Indexing/SkillIndexer.cs @@ -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; + +/// Parses a SKILL.md, computes its embedding, and upserts the Skill row (by key+version). +internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder, TimeProvider clock) +{ + public async Task 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; + } +} diff --git a/src/Modules/TeamUp.Modules.Skills/Parsing/SkillMarkdownParser.cs b/src/Modules/TeamUp.Modules.Skills/Parsing/SkillMarkdownParser.cs new file mode 100644 index 0000000..a1f4dae --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Parsing/SkillMarkdownParser.cs @@ -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); + +/// Splits a SKILL.md into its YAML frontmatter (between '---' fences) and markdown body. +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(frontmatter) ?? new SkillManifest(); + if (string.IsNullOrWhiteSpace(manifest.Id)) + { + throw new FormatException("SKILL.md frontmatter must include an 'id'."); + } + + return new ParsedSkill(manifest, body); + } +} diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.Designer.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.Designer.cs new file mode 100644 index 0000000..69f3c90 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.Designer.cs @@ -0,0 +1,188 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.PrimitiveCollection>("Context") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Embedding") + .HasColumnType("vector(384)"); + + b.Property("IndexedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Inputs") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("MinTier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Outputs") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SkillKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceCommit") + .HasColumnType("text"); + + b.Property("SourcePath") + .HasColumnType("text"); + + b.Property("SourceRepo") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.PrimitiveCollection>("Tools") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("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("SkillId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Expected") + .IsRequired(); + + b1.Property("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("SkillId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Description"); + + b1.Property("Name") + .IsRequired(); + + b1.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.cs new file mode 100644 index 0000000..a12d5a9 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260609141931_InitialSkills.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Pgvector; + +#nullable disable + +namespace TeamUp.Modules.Skills.Persistence.Migrations +{ + /// + public partial class InitialSkills : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "skills"); + + migrationBuilder.CreateTable( + name: "skills", + schema: "skills", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SkillKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Version = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Summary = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + Roles = table.Column>(type: "text[]", nullable: false), + Inputs = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Outputs = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Tools = table.Column>(type: "text[]", nullable: false), + Context = table.Column>(type: "text[]", nullable: false), + Visibility = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + MinTier = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Body = table.Column(type: "text", nullable: false), + ContentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SourceRepo = table.Column(type: "text", nullable: true), + SourcePath = table.Column(type: "text", nullable: true), + SourceCommit = table.Column(type: "text", nullable: true), + Embedding = table.Column(type: "vector(384)", nullable: true), + IndexedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + Actions = table.Column(type: "jsonb", nullable: true), + GoldenTests = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "skills", + schema: "skills"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/SkillsDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/SkillsDbContextModelSnapshot.cs new file mode 100644 index 0000000..ad4f140 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/SkillsDbContextModelSnapshot.cs @@ -0,0 +1,185 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.PrimitiveCollection>("Context") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Embedding") + .HasColumnType("vector(384)"); + + b.Property("IndexedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Inputs") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("MinTier") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Outputs") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SkillKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceCommit") + .HasColumnType("text"); + + b.Property("SourcePath") + .HasColumnType("text"); + + b.Property("SourceRepo") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.PrimitiveCollection>("Tools") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("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("SkillId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Expected") + .IsRequired(); + + b1.Property("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("SkillId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Description"); + + b1.Property("Name") + .IsRequired(); + + b1.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContext.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContext.cs new file mode 100644 index 0000000..34d8cb2 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContext.cs @@ -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 options) + : DbContext(options), IModuleDbContext +{ + public DbSet Skills => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("skills"); + + modelBuilder.Entity(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().HasMaxLength(20); + skill.Property(s => s.MinTier).HasConversion().HasMaxLength(20); + skill.Property(s => s.Status).HasConversion().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); + }); + } +} diff --git a/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContextFactory.cs b/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContextFactory.cs new file mode 100644 index 0000000..74125b6 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Skills/Persistence/SkillsDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace TeamUp.Modules.Skills.Persistence; + +/// Design-time factory so `dotnet ef` can build the internal context (with the pgvector handler). +internal sealed class SkillsDbContextFactory : IDesignTimeDbContextFactory +{ + 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() + .UseNpgsql(connectionString, npgsql => npgsql.UseVector()) + .Options; + + return new SkillsDbContext(options); + } +} diff --git a/src/Modules/TeamUp.Modules.Skills/SkillsModule.cs b/src/Modules/TeamUp.Modules.Skills/SkillsModule.cs index 4e30525..17a71d4 100644 --- a/src/Modules/TeamUp.Modules.Skills/SkillsModule.cs +++ b/src/Modules/TeamUp.Modules.Skills/SkillsModule.cs @@ -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; -/// Git-sourced skill registry: sync, the queryable atom index, versioning, evals (M2). +/// Git-sourced skill registry: the queryable atom index, versioning, the eval harness (M2). 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(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector())); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddScoped(); + 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); } diff --git a/src/Modules/TeamUp.Modules.Skills/TeamUp.Modules.Skills.csproj b/src/Modules/TeamUp.Modules.Skills/TeamUp.Modules.Skills.csproj index 65f5856..662caa0 100644 --- a/src/Modules/TeamUp.Modules.Skills/TeamUp.Modules.Skills.csproj +++ b/src/Modules/TeamUp.Modules.Skills/TeamUp.Modules.Skills.csproj @@ -1,10 +1,19 @@ - + + + + + + + + + + diff --git a/tests/TeamUp.IntegrationTests/SkillRegistryTests.cs b/tests/TeamUp.IntegrationTests/SkillRegistryTests.cs new file mode 100644 index 0000000..63dc2e3 --- /dev/null +++ b/tests/TeamUp.IntegrationTests/SkillRegistryTests.cs @@ -0,0 +1,109 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// M2 skill-registry acceptance: index a SKILL.md, then find it queryable by role; a skill with +/// roles + golden tests publishes, a malformed one is rejected, and the catalogue needs auth. +/// +public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture +{ + private const string SpecWritingSkill = + """ + --- + id: spec-writing + name: Spec Writing + version: 1.0.0 + summary: Turn a feature request into a structured spec and child stories. + roles: [product-owner] + inputs: A feature request or task. + outputs: A spec plus proposed child stories. + actions: + - name: write-spec + risk: draft + - name: create-child-stories + risk: draft + tools: [] + context: [house-style] + visibility: public + min_tier: free + golden_tests: + - input: "Add a logout button" + expected: "Spec: a logout button in the header that ends the session." + --- + + # Spec Writing + + Write a clear, testable spec, then propose child stories. + """; + + private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); + + private sealed record ActionDto(string Name, string Risk); + + private sealed record SkillSummary( + string SkillKey, string Name, string Version, string? Summary, List Roles, + string Visibility, string MinTier, string Status, List Actions); + + private sealed record SkillDetail( + SkillSummary Skill, string? Inputs, string? Outputs, List Tools, + List Context, int GoldenTestCount, string Body); + + [Fact] + public async Task Index_publishes_and_makes_skill_queryable_by_role() + { + await using var factory = new TeamUpWebFactory(postgres.ConnectionString); + using var anon = factory.CreateClient(); + + // The catalogue requires auth. + Assert.Equal(HttpStatusCode.Unauthorized, (await anon.GetAsync("/api/skills/")).StatusCode); + + var bootstrap = await anon.PostAsJsonAsync("/api/identity/bootstrap", new + { + organizationName = "AliaSaaS", + ownerEmail = "owner@alia.test", + ownerDisplayName = "Owner", + ownerPassword = "Passw0rd!", + }); + var owner = await bootstrap.Content.ReadFromJsonAsync(); + Assert.NotNull(owner); + + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token); + + // Index the SKILL.md. + var indexResponse = await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill }); + Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode); + var indexed = await indexResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(indexed); + Assert.Equal("spec-writing", indexed!.Skill.SkillKey); + Assert.Equal("Published", indexed.Skill.Status); // has roles + a golden test + Assert.Equal(1, indexed.GoldenTestCount); + Assert.Contains(indexed.Skill.Actions, a => a.Name == "write-spec" && a.Risk == "Draft"); + + // Queryable by its role… + var forPo = await client.GetFromJsonAsync>("/api/skills/?role=product-owner"); + Assert.Contains(forPo!, s => s.SkillKey == "spec-writing"); + + // …but not under an unrelated role. + var forQa = await client.GetFromJsonAsync>("/api/skills/?role=qa"); + Assert.DoesNotContain(forQa!, s => s.SkillKey == "spec-writing"); + + // Detail by key. + var detail = await client.GetFromJsonAsync>("/api/skills/spec-writing"); + Assert.Single(detail!); + Assert.Equal("public", detail![0].Skill.Visibility.ToLowerInvariant()); + + // Re-indexing the same key+version updates in place (no duplicate). + await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill }); + var afterReindex = await client.GetFromJsonAsync>("/api/skills/spec-writing"); + Assert.Single(afterReindex!); + + // A malformed SKILL.md (no frontmatter) is rejected. + var bad = await client.PostAsJsonAsync("/api/skills/index", new { content = "# no frontmatter here" }); + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + } +}