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:
@@ -32,6 +32,7 @@
|
|||||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||||
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageVersion Include="Riok.Mapperly" Version="4.3.1" />
|
<PackageVersion Include="Riok.Mapperly" Version="4.3.1" />
|
||||||
|
<PackageVersion Include="YamlDotNet" Version="18.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Label="Observability">
|
<ItemGroup Label="Observability">
|
||||||
|
|||||||
@@ -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.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
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.Modularity;
|
||||||
|
using TeamUp.SharedKernel.Persistence;
|
||||||
|
|
||||||
namespace TeamUp.Modules.Skills;
|
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 sealed class SkillsModule : IModule
|
||||||
{
|
{
|
||||||
public string Name => "skills";
|
public string Name => "skills";
|
||||||
|
|
||||||
public void Register(IServiceCollection services, IConfiguration configuration)
|
public void Register(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Skeleton: no services yet. M2 introduces this module's (internal) DbContext,
|
var connectionString = configuration.GetConnectionString("Postgres")
|
||||||
// FluentValidation validators, and domain services here.
|
?? 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)
|
public void MapEndpoints(IEndpointRouteBuilder endpoints) => SkillsEndpoints.Map(endpoints);
|
||||||
{
|
|
||||||
endpoints.MapGroup($"/api/{Name}")
|
|
||||||
.WithTags("Skills")
|
|
||||||
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
|
<!-- Git-sourced skill registry: SKILL.md (YAML frontmatter) parsed into a queryable Postgres +
|
||||||
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
|
pgvector index, with an eval/golden harness. References SharedKernel only; reads Git through
|
||||||
gains an (internal) DbContext and validators. It must never reference another module. -->
|
the SharedKernel IGitProvider seam (implemented by Integrations). -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeamUp.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||||
|
{
|
||||||
|
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<string> Roles,
|
||||||
|
string Visibility, string MinTier, string Status, List<ActionDto> Actions);
|
||||||
|
|
||||||
|
private sealed record SkillDetail(
|
||||||
|
SkillSummary Skill, string? Inputs, string? Outputs, List<string> Tools,
|
||||||
|
List<string> 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<BootstrapResponse>();
|
||||||
|
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<SkillDetail>();
|
||||||
|
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<List<SkillSummary>>("/api/skills/?role=product-owner");
|
||||||
|
Assert.Contains(forPo!, s => s.SkillKey == "spec-writing");
|
||||||
|
|
||||||
|
// …but not under an unrelated role.
|
||||||
|
var forQa = await client.GetFromJsonAsync<List<SkillSummary>>("/api/skills/?role=qa");
|
||||||
|
Assert.DoesNotContain(forQa!, s => s.SkillKey == "spec-writing");
|
||||||
|
|
||||||
|
// Detail by key.
|
||||||
|
var detail = await client.GetFromJsonAsync<List<SkillDetail>>("/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<List<SkillDetail>>("/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user