Skip to content

Commit 48c56c7

Browse files
committed
feat: add skill auto-recommendation with multi-language support
- Implement skill auto-recommendation based on user prompts - Add recommend_skills MCP tool - Support 5 languages (English, Korean, Japanese, Chinese, Spanish) - Keyword-based pattern matching with priority system - Provide confidence levels (high, medium, low) - Comprehensive test coverage close #118
1 parent 7ef1e11 commit 48c56c7

20 files changed

Lines changed: 1816 additions & 7 deletions

apps/mcp-server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ npx codingbuddy init
4141
| `search_rules` | Search through rules and guidelines |
4242
| `get_agent_details` | Get detailed profile of a specialist agent |
4343
| `parse_mode` | Parse PLAN/ACT/EVAL workflow mode (includes language setting) |
44+
| `recommend_skills` | Recommend skills based on user prompt with multi-language support |
4445

4546
### MCP Prompts
4647

apps/mcp-server/src/mcp/mcp.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { RulesModule } from '../rules/rules.module';
55
import { KeywordModule } from '../keyword/keyword.module';
66
import { CodingBuddyConfigModule } from '../config/config.module';
77
import { AnalyzerModule } from '../analyzer/analyzer.module';
8+
import { SkillRecommendationService } from '../skill/skill-recommendation.service';
89

910
@Module({
1011
imports: [
@@ -14,7 +15,7 @@ import { AnalyzerModule } from '../analyzer/analyzer.module';
1415
AnalyzerModule,
1516
],
1617
controllers: [McpController],
17-
providers: [McpService],
18+
providers: [McpService, SkillRecommendationService],
1819
exports: [McpService],
1920
})
2021
export class McpModule {}

apps/mcp-server/src/mcp/mcp.service.spec.ts

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
ConfigDiffResult,
1010
} from '../config/config-diff.service';
1111
import { AnalyzerService } from '../analyzer/analyzer.service';
12+
import { SkillRecommendationService } from '../skill/skill-recommendation.service';
13+
import type { RecommendSkillsResult } from '../skill/skill-recommendation.types';
1214

1315
// Handler function type for MCP request handlers
1416
type McpHandler = (request: unknown) => Promise<unknown>;
@@ -139,6 +141,21 @@ const createMockAnalyzerService = (): Partial<AnalyzerService> => ({
139141
analyzeProject: vi.fn().mockResolvedValue(mockAnalysis),
140142
});
141143

144+
const createMockSkillRecommendationService =
145+
(): Partial<SkillRecommendationService> => ({
146+
recommendSkills: vi.fn().mockReturnValue({
147+
recommendations: [
148+
{
149+
skillName: 'systematic-debugging',
150+
confidence: 'high',
151+
matchedPatterns: ['bug', 'debug'],
152+
description: 'Systematic approach to debugging',
153+
},
154+
],
155+
originalPrompt: 'I have a bug in my code',
156+
} as RecommendSkillsResult),
157+
});
158+
142159
// Import after mocks
143160
import { McpService } from './mcp.service';
144161

@@ -148,6 +165,7 @@ describe('McpService', () => {
148165
let mockConfigService: Partial<ConfigService>;
149166
let mockConfigDiffService: Partial<ConfigDiffService>;
150167
let mockAnalyzerService: Partial<AnalyzerService>;
168+
let mockSkillRecommendationService: Partial<SkillRecommendationService>;
151169

152170
const testConfig: CodingBuddyConfig = {
153171
language: 'ko',
@@ -167,13 +185,15 @@ describe('McpService', () => {
167185
mockConfigService = createMockConfigService(testConfig);
168186
mockConfigDiffService = createMockConfigDiffService();
169187
mockAnalyzerService = createMockAnalyzerService();
188+
mockSkillRecommendationService = createMockSkillRecommendationService();
170189

171190
const mcpService = new McpService(
172191
mockRulesService as RulesService,
173192
mockKeywordService as KeywordService,
174193
mockConfigService as ConfigService,
175194
mockConfigDiffService as ConfigDiffService,
176195
mockAnalyzerService as AnalyzerService,
196+
mockSkillRecommendationService as SkillRecommendationService,
177197
);
178198
mcpService.onModuleInit();
179199
});
@@ -384,6 +404,7 @@ describe('McpService', () => {
384404
emptyConfigService as ConfigService,
385405
mockConfigDiffService as ConfigDiffService,
386406
mockAnalyzerService as AnalyzerService,
407+
mockSkillRecommendationService as SkillRecommendationService,
387408
);
388409
serviceWithEmptyConfig.onModuleInit();
389410

@@ -432,6 +453,7 @@ describe('McpService', () => {
432453
fullConfigService as ConfigService,
433454
mockConfigDiffService as ConfigDiffService,
434455
mockAnalyzerService as AnalyzerService,
456+
mockSkillRecommendationService as SkillRecommendationService,
435457
);
436458
service.onModuleInit();
437459

@@ -495,6 +517,7 @@ describe('McpService', () => {
495517
failingConfigService as ConfigService,
496518
mockConfigDiffService as ConfigDiffService,
497519
mockAnalyzerService as AnalyzerService,
520+
mockSkillRecommendationService as SkillRecommendationService,
498521
);
499522
service.onModuleInit();
500523

@@ -565,6 +588,7 @@ describe('McpService', () => {
565588
failingConfigService as ConfigService,
566589
mockConfigDiffService as ConfigDiffService,
567590
mockAnalyzerService as AnalyzerService,
591+
mockSkillRecommendationService as SkillRecommendationService,
568592
);
569593
service.onModuleInit();
570594

@@ -620,6 +644,7 @@ describe('McpService', () => {
620644
mockConfigService as ConfigService,
621645
mockConfigDiffService as ConfigDiffService,
622646
mockAnalyzerService as AnalyzerService,
647+
mockSkillRecommendationService as SkillRecommendationService,
623648
);
624649

625650
// Should not throw
@@ -635,10 +660,276 @@ describe('McpService', () => {
635660
mockConfigService as ConfigService,
636661
mockConfigDiffService as ConfigDiffService,
637662
mockAnalyzerService as AnalyzerService,
663+
mockSkillRecommendationService as SkillRecommendationService,
638664
);
639665

640666
const server = service.getServer();
641667
expect(server).toBeDefined();
642668
});
643669
});
670+
671+
// ============================================================================
672+
// recommend_skills Tool Tests (RED phase - tests should FAIL)
673+
// ============================================================================
674+
675+
describe('recommend_skills tool', () => {
676+
describe('Tool Registration', () => {
677+
it('should list recommend_skills tool', async () => {
678+
const handler = handlers.get('tools/list');
679+
expect(handler).toBeDefined();
680+
681+
const result = (await handler!({})) as {
682+
tools: { name: string; description: string; inputSchema: object }[];
683+
};
684+
const recommendTool = result.tools.find(
685+
t => t.name === 'recommend_skills',
686+
);
687+
688+
expect(recommendTool).toBeDefined();
689+
expect(recommendTool!.description).toContain('skill');
690+
});
691+
692+
it('should have correct inputSchema for recommend_skills', async () => {
693+
const handler = handlers.get('tools/list');
694+
expect(handler).toBeDefined();
695+
696+
const result = (await handler!({})) as {
697+
tools: {
698+
name: string;
699+
inputSchema: { properties: object; required: string[] };
700+
}[];
701+
};
702+
const recommendTool = result.tools.find(
703+
t => t.name === 'recommend_skills',
704+
);
705+
706+
expect(recommendTool).toBeDefined();
707+
expect(recommendTool!.inputSchema.properties).toHaveProperty('prompt');
708+
expect(recommendTool!.inputSchema.required).toContain('prompt');
709+
});
710+
});
711+
712+
describe('Basic Functionality', () => {
713+
it('should return recommendations for debugging prompt', async () => {
714+
const handler = handlers.get('tools/call');
715+
expect(handler).toBeDefined();
716+
717+
const result = (await handler!({
718+
params: {
719+
name: 'recommend_skills',
720+
arguments: { prompt: 'I have a bug in my code' },
721+
},
722+
})) as { content: { type: string; text: string }[] };
723+
724+
expect(result.content).toHaveLength(1);
725+
expect(result.content[0].type).toBe('text');
726+
727+
const parsed = JSON.parse(result.content[0].text);
728+
expect(parsed.recommendations).toBeDefined();
729+
expect(parsed.recommendations.length).toBeGreaterThan(0);
730+
expect(parsed.recommendations[0].skillName).toBe(
731+
'systematic-debugging',
732+
);
733+
expect(
734+
mockSkillRecommendationService.recommendSkills,
735+
).toHaveBeenCalledWith('I have a bug in my code');
736+
});
737+
738+
it('should return empty recommendations for unrelated prompt', async () => {
739+
vi.mocked(
740+
mockSkillRecommendationService.recommendSkills!,
741+
).mockReturnValue({
742+
recommendations: [],
743+
originalPrompt: 'What is the weather today?',
744+
});
745+
746+
const handler = handlers.get('tools/call');
747+
expect(handler).toBeDefined();
748+
749+
const result = (await handler!({
750+
params: {
751+
name: 'recommend_skills',
752+
arguments: { prompt: 'What is the weather today?' },
753+
},
754+
})) as { content: { type: string; text: string }[] };
755+
756+
const parsed = JSON.parse(result.content[0].text);
757+
expect(parsed.recommendations).toEqual([]);
758+
});
759+
760+
it('should include originalPrompt in response', async () => {
761+
const handler = handlers.get('tools/call');
762+
expect(handler).toBeDefined();
763+
764+
const result = (await handler!({
765+
params: {
766+
name: 'recommend_skills',
767+
arguments: { prompt: 'I have a bug in my code' },
768+
},
769+
})) as { content: { type: string; text: string }[] };
770+
771+
const parsed = JSON.parse(result.content[0].text);
772+
expect(parsed.originalPrompt).toBe('I have a bug in my code');
773+
});
774+
});
775+
776+
describe('Multi-language Support', () => {
777+
it('should work with English prompt', async () => {
778+
vi.mocked(
779+
mockSkillRecommendationService.recommendSkills!,
780+
).mockReturnValue({
781+
recommendations: [
782+
{
783+
skillName: 'systematic-debugging',
784+
confidence: 'high',
785+
matchedPatterns: ['bug'],
786+
description: 'Systematic approach to debugging',
787+
},
788+
],
789+
originalPrompt: 'There is a bug in the login feature',
790+
});
791+
792+
const handler = handlers.get('tools/call');
793+
expect(handler).toBeDefined();
794+
795+
const result = (await handler!({
796+
params: {
797+
name: 'recommend_skills',
798+
arguments: { prompt: 'There is a bug in the login feature' },
799+
},
800+
})) as { content: { type: string; text: string }[] };
801+
802+
const parsed = JSON.parse(result.content[0].text);
803+
expect(parsed.recommendations).toHaveLength(1);
804+
expect(parsed.recommendations[0].skillName).toBe(
805+
'systematic-debugging',
806+
);
807+
});
808+
809+
it('should work with Korean prompt', async () => {
810+
vi.mocked(
811+
mockSkillRecommendationService.recommendSkills!,
812+
).mockReturnValue({
813+
recommendations: [
814+
{
815+
skillName: 'systematic-debugging',
816+
confidence: 'high',
817+
matchedPatterns: ['버그'],
818+
description: 'Systematic approach to debugging',
819+
},
820+
],
821+
originalPrompt: '로그인에 버그가 있습니다',
822+
});
823+
824+
const handler = handlers.get('tools/call');
825+
expect(handler).toBeDefined();
826+
827+
const result = (await handler!({
828+
params: {
829+
name: 'recommend_skills',
830+
arguments: { prompt: '로그인에 버그가 있습니다' },
831+
},
832+
})) as { content: { type: string; text: string }[] };
833+
834+
const parsed = JSON.parse(result.content[0].text);
835+
expect(parsed.recommendations).toHaveLength(1);
836+
expect(
837+
mockSkillRecommendationService.recommendSkills,
838+
).toHaveBeenCalledWith('로그인에 버그가 있습니다');
839+
});
840+
841+
it('should work with Japanese prompt', async () => {
842+
vi.mocked(
843+
mockSkillRecommendationService.recommendSkills!,
844+
).mockReturnValue({
845+
recommendations: [
846+
{
847+
skillName: 'systematic-debugging',
848+
confidence: 'high',
849+
matchedPatterns: ['バグ'],
850+
description: 'Systematic approach to debugging',
851+
},
852+
],
853+
originalPrompt: 'ログイン機能にバグがある',
854+
});
855+
856+
const handler = handlers.get('tools/call');
857+
expect(handler).toBeDefined();
858+
859+
const result = (await handler!({
860+
params: {
861+
name: 'recommend_skills',
862+
arguments: { prompt: 'ログイン機能にバグがある' },
863+
},
864+
})) as { content: { type: string; text: string }[] };
865+
866+
const parsed = JSON.parse(result.content[0].text);
867+
expect(parsed.recommendations).toHaveLength(1);
868+
expect(
869+
mockSkillRecommendationService.recommendSkills,
870+
).toHaveBeenCalledWith('ログイン機能にバグがある');
871+
});
872+
});
873+
874+
describe('Error Handling', () => {
875+
it('should throw error when prompt is missing', async () => {
876+
const handler = handlers.get('tools/call');
877+
expect(handler).toBeDefined();
878+
879+
const result = (await handler!({
880+
params: {
881+
name: 'recommend_skills',
882+
arguments: {},
883+
},
884+
})) as { isError: boolean; content: { text: string }[] };
885+
886+
expect(result.isError).toBe(true);
887+
expect(result.content[0].text).toContain('prompt');
888+
});
889+
890+
it('should return empty recommendations for empty prompt', async () => {
891+
vi.mocked(
892+
mockSkillRecommendationService.recommendSkills!,
893+
).mockReturnValue({
894+
recommendations: [],
895+
originalPrompt: '',
896+
});
897+
898+
const handler = handlers.get('tools/call');
899+
expect(handler).toBeDefined();
900+
901+
const result = (await handler!({
902+
params: {
903+
name: 'recommend_skills',
904+
arguments: { prompt: '' },
905+
},
906+
})) as { content: { type: string; text: string }[] };
907+
908+
const parsed = JSON.parse(result.content[0].text);
909+
expect(parsed.recommendations).toEqual([]);
910+
expect(parsed.originalPrompt).toBe('');
911+
});
912+
913+
it('should return error when service throws', async () => {
914+
vi.mocked(
915+
mockSkillRecommendationService.recommendSkills!,
916+
).mockImplementation(() => {
917+
throw new Error('Service error');
918+
});
919+
920+
const handler = handlers.get('tools/call');
921+
expect(handler).toBeDefined();
922+
923+
const result = (await handler!({
924+
params: {
925+
name: 'recommend_skills',
926+
arguments: { prompt: 'test prompt' },
927+
},
928+
})) as { isError: boolean; content: { text: string }[] };
929+
930+
expect(result.isError).toBe(true);
931+
expect(result.content[0].text).toContain('Failed to recommend skills');
932+
});
933+
});
934+
});
644935
});

0 commit comments

Comments
 (0)