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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user