Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/SkillServer/migrations/002_fts5_porter_stemmer.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
-- 002_fts5_porter_stemmer.sql: Enable Porter stemmer for better search matching

-- Drop existing triggers before dropping the table
DROP TRIGGER IF EXISTS trg_skills_fts_insert;
DROP TRIGGER IF EXISTS trg_skills_fts_delete;

-- Recreate FTS table with porter stemmer tokenizer
DROP TABLE IF EXISTS skills_fts;

CREATE VIRTUAL TABLE skills_fts USING fts5(
name,
description,
category,
tokenize='porter unicode61'
);

-- Recreate triggers to keep FTS in sync
CREATE TRIGGER trg_skills_fts_insert
AFTER INSERT ON skill_versions
WHEN NEW.is_latest = 1
BEGIN
DELETE FROM skills_fts WHERE rowid = NEW.skill_id;
INSERT INTO skills_fts(rowid, name, description, category)
SELECT NEW.skill_id, s.name, NEW.description, COALESCE(NEW.category, '')
FROM skills s WHERE s.id = NEW.skill_id;
END;

CREATE TRIGGER trg_skills_fts_delete
AFTER DELETE ON skill_versions
BEGIN
DELETE FROM skills_fts WHERE rowid = OLD.skill_id;
INSERT INTO skills_fts(rowid, name, description, category)
SELECT s.id, s.name, sv.description, COALESCE(sv.category, '')
FROM skills s
JOIN skill_versions sv ON sv.skill_id = s.id AND sv.is_latest = 1
WHERE s.id = OLD.skill_id;
END;

-- Repopulate FTS index from existing data
INSERT INTO skills_fts(rowid, name, description, category)
SELECT s.id, s.name, sv.description, COALESCE(sv.category, '')
FROM skills s
JOIN skill_versions sv ON sv.skill_id = s.id AND sv.is_latest = 1;
35 changes: 35 additions & 0 deletions tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -571,4 +571,39 @@ public async Task UploadSkill_DifferentVersions_AreAllowed()
var versions = await _fixture.Client.GetSkillVersionsAsync(skillName, ct);
Assert.Equal(2, versions.Count);
}

[Fact]
public async Task SearchSkills_WithPorterStemming_MatchesStemmedTerms()
{
var ct = TestContext.Current.CancellationToken;
var prefix = $"stem-{Guid.NewGuid():N}"[..10];
var skillName = $"{prefix}-closer";

var skillContent = $"""
---
name: {skillName}
description: Helps sales reps close deal opportunities faster
---

# Stemming Test
""";

using var content = new MultipartFormDataContent();
content.Add(new StringContent(skillName), "name");
content.Add(new StringContent("1.0.0"), "version");

var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
content.Add(fileContent, "file", "SKILL.md");

await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct);

// "closed deals" should match "close deal" via porter stemming
var results = await _fixture.Client.SearchSkillsAsync("closed deals", ct: ct);
Assert.Contains(results, s => s.Name == skillName);

// "closing" should match "close" via stemming
results = await _fixture.Client.SearchSkillsAsync("closing", ct: ct);
Assert.Contains(results, s => s.Name == skillName);
}
}
Loading