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
13 changes: 12 additions & 1 deletion src/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/skills/suggestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
37 changes: 36 additions & 1 deletion tests/api/test_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
109 changes: 108 additions & 1 deletion tests/skills/test_suggestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Loading