diff --git a/src/api/schemas.py b/src/api/schemas.py index e2e5fba..df5211e 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -1597,9 +1597,20 @@ class SkillCatalogItem(BaseModel): ) -class SkillSuggestionRequest(ProjectScopedRequest): +class SkillSuggestionRequest(BaseModel): """Request to suggest skills for a role in a project.""" + model_config = ConfigDict(populate_by_name=True) + + project_id: ProjectId | None = Field( + alias="projectId", + default=None, + description=( + "Optional project this request is scoped to. When absent, retrieval " + "is skipped and only universal/role-typical skills are suggested." + ), + examples=["3f1c0b1e-1f4d-4a5e-9b6a-0d2c8f7e5a11"], + ) role_name: str = Field( alias="roleName", min_length=1, diff --git a/src/skills/suggestion.py b/src/skills/suggestion.py index c02fab2..6fd14ad 100644 --- a/src/skills/suggestion.py +++ b/src/skills/suggestion.py @@ -137,7 +137,7 @@ def suggest_skills( llm: LLMClient, store: VectorStore, *, - project_id: str, + project_id: str | None = None, role_name: str, role_description: str = "", project_industry: str | None = None, @@ -164,7 +164,7 @@ def suggest_skills( cache = bm25_cache if bm25_cache is not None else get_bm25_cache() chunks: list[ScoredChunk] = [] - if store.count() > 0: + if project_id is not None and store.count() > 0: chunks = hybrid_retrieve( question=query, llm=llm, diff --git a/tests/api/test_skills.py b/tests/api/test_skills.py index 8f4527c..396db6b 100644 --- a/tests/api/test_skills.py +++ b/tests/api/test_skills.py @@ -141,11 +141,46 @@ def test_suggest_skills_api_degradation_on_parse_error( assert response.json() == {"suggestions": []} +def test_suggest_skills_api_200_without_project_id( + client: tuple[TestClient, StubLLMClient, StubVectorStore], +) -> None: + http, _, _ = client + + # projectId omitted from request + payload = { + "roleName": "Frontend Developer", + "roleDescription": "Builds user interfaces", + "projectIndustry": "Healthcare", + "availableSkills": [ + { + "id": "s1", + "name": "React", + "category": "Frontend & UI", + "universal": False, + }, + { + "id": "s2", + "name": "Communication", + "category": "Soft Skills", + "universal": True, + }, + ], + } + + response = http.post(f"{_BASE}/suggest", json=payload) + assert response.status_code == 200, response.text + data = response.json() + assert "suggestions" in data + # Only universal skill survives grounding without project context + assert len(data["suggestions"]) == 1 + assert data["suggestions"][0]["name"] == "Communication" + + def test_suggest_skills_api_422_missing_fields( client: tuple[TestClient, StubLLMClient, StubVectorStore], ) -> None: http, _, _ = client - # Missing roleName and projectId + # Missing required roleName response = http.post(f"{_BASE}/suggest", json={}) assert response.status_code == 422 diff --git a/tests/skills/test_suggestion.py b/tests/skills/test_suggestion.py index 13c2a48..7cfff63 100644 --- a/tests/skills/test_suggestion.py +++ b/tests/skills/test_suggestion.py @@ -5,7 +5,7 @@ from api.schemas import SkillCatalogItem from llm.base import Message from llm.errors import LLMUnavailableError -from rag.types import Chunk +from rag.types import Chunk, ScoredChunk from skills.suggestion import _build_prompt, suggest_skills from tests.stubs.llm import StubLLMClient from tests.stubs.store import StubVectorStore @@ -386,3 +386,110 @@ def test_build_prompt_structure() -> None: assert "Role: Fullstack Developer" in messages[1]["content"] assert "Description: Works on backend and frontend" in messages[1]["content"] assert "Project industry/domain: Fintech" in messages[1]["content"] + + +def test_suggest_skills_without_project_id_skips_retrieval() -> None: + store = _make_store_with_chunks() + catalog = [ + SkillCatalogItem( + id="s1", name="React", category="Frontend & UI", universal=False + ), + SkillCatalogItem( + id="s2", name="Communication", category="Soft Skills", universal=True + ), + ] + + llm_payload = { + "suggestions": [ + { + "name": "React", + "category": "Frontend & UI", + "reason": "React components found", + "confidence": "high", + "universal": False, + "isNew": False, + "chunkIds": ["c-react"], + }, + { + "name": "Communication", + "category": "Soft Skills", + "reason": "Essential for teamwork", + "confidence": "high", + "universal": True, + "isNew": False, + "chunkIds": [], + }, + ] + } + llm = StubLLMClient(generate_response=json.dumps(llm_payload)) + llm.embedding = _EMBED + + # project_id is omitted / None + result = suggest_skills( + llm=llm, + store=store, + project_id=None, + role_name="Developer", + project_industry="Banking", + available_skills=catalog, + ) + + # React is dropped because no retrieval occurred; Communication passes + assert len(result.suggestions) == 1 + assert result.suggestions[0].name == "Communication" + assert result.suggestions[0].chunk_ids == [] + + +def test_suggest_skills_without_project_id_does_not_query_store() -> None: + class QueryCountingStore(StubVectorStore): + query_count = 0 + + def query(self, *args: object, **kwargs: object) -> list[ScoredChunk]: + self.query_count += 1 + return super().query(*args, **kwargs) + + store = QueryCountingStore() + store.add( + [ + Chunk( + id="c1", + artifact_id="a1", + filename="README.md", + text="project info", + embedding=_EMBED, + project_ids=("proj-1",), + ) + ] + ) + catalog = [ + SkillCatalogItem( + id="s1", name="Teamwork", category="Soft Skills", universal=True + ) + ] + llm_payload = { + "suggestions": [ + { + "name": "Teamwork", + "category": "Soft Skills", + "reason": "Team collaboration", + "confidence": "high", + "universal": True, + "isNew": False, + "chunkIds": [], + } + ] + } + llm = StubLLMClient(generate_response=json.dumps(llm_payload)) + llm.embedding = _EMBED + + result = suggest_skills( + llm=llm, + store=store, + project_id=None, + role_name="Developer", + available_skills=catalog, + ) + + assert store.query_count == 0 + assert len(result.suggestions) == 1 + assert result.suggestions[0].name == "Teamwork"