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:
@@ -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 M3–M4;
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
Generated
+188
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user