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,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