Add name suggestion engine with API endpoint and tests
Expand services/naming.py with suggest_names() that picks random words
from a category while excluding nicknames already used in the run. Add
GET /runs/{run_id}/name-suggestions?count=10 endpoint that reads the
run's naming_scheme and returns filtered suggestions. Includes 12 unit
tests covering selection, exclusion, exhaustion, and cross-category
independence.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-c6ly
|
# nuzlocke-tracker-c6ly
|
||||||
title: Build name suggestion engine
|
title: Build name suggestion engine
|
||||||
status: todo
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-11T15:56:44Z
|
created_at: 2026-02-11T15:56:44Z
|
||||||
updated_at: 2026-02-11T20:23:35Z
|
updated_at: 2026-02-11T20:44:27Z
|
||||||
parent: nuzlocke-tracker-igl3
|
parent: nuzlocke-tracker-igl3
|
||||||
blocking:
|
blocking:
|
||||||
- nuzlocke-tracker-bi4e
|
- nuzlocke-tracker-bi4e
|
||||||
@@ -29,8 +29,8 @@ Build the backend service and API endpoint that picks random name suggestions fr
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Create a service module for name suggestion logic (e.g. `services/name_suggestions.py`)
|
- [x] Create a service module for name suggestion logic (e.g. `services/name_suggestions.py`)
|
||||||
- [ ] Implement dictionary loading with in-memory caching
|
- [x] Implement dictionary loading with in-memory caching
|
||||||
- [ ] Implement random selection from a category with exclusion of used names
|
- [x] Implement random selection from a category with exclusion of used names
|
||||||
- [ ] Add API endpoint for fetching suggestions
|
- [x] Add API endpoint for fetching suggestions
|
||||||
- [ ] Add unit tests for the suggestion logic
|
- [x] Add unit tests for the suggestion logic
|
||||||
@@ -5,7 +5,7 @@ status: todo
|
|||||||
type: epic
|
type: epic
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-05T13:45:15Z
|
created_at: 2026-02-05T13:45:15Z
|
||||||
updated_at: 2026-02-11T20:41:56Z
|
updated_at: 2026-02-11T20:44:23Z
|
||||||
---
|
---
|
||||||
|
|
||||||
Implement a dictionary-based nickname generation system for Nuzlocke runs. Instead of using an LLM API to generate names on the fly, provide a static dictionary of words categorised by theme. A word can belong to multiple categories, making it usable across different naming schemes.
|
Implement a dictionary-based nickname generation system for Nuzlocke runs. Instead of using an LLM API to generate names on the fly, provide a static dictionary of words categorised by theme. A word can belong to multiple categories, making it usable across different naming schemes.
|
||||||
@@ -28,7 +28,7 @@ Implement a dictionary-based nickname generation system for Nuzlocke runs. Inste
|
|||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
- [x] Word dictionary data file exists with multiple categories, each containing 150-200 words
|
- [x] Word dictionary data file exists with multiple categories, each containing 150-200 words
|
||||||
- [ ] Name suggestion engine picks random names from the selected category, avoiding duplicates already used in the run
|
- [x] Name suggestion engine picks random names from the selected category, avoiding duplicates already used in the run
|
||||||
- [ ] Encounter registration UI shows 5-10 clickable name suggestions
|
- [ ] Encounter registration UI shows 5-10 clickable name suggestions
|
||||||
- [ ] User can regenerate suggestions if none fit
|
- [ ] User can regenerate suggestions if none fit
|
||||||
- [x] User can select a naming scheme per run
|
- [x] User can select a naming scheme per run
|
||||||
@@ -19,7 +19,7 @@ from app.schemas.run import (
|
|||||||
RunResponse,
|
RunResponse,
|
||||||
RunUpdate,
|
RunUpdate,
|
||||||
)
|
)
|
||||||
from app.services.naming import get_naming_categories
|
from app.services.naming import get_naming_categories, suggest_names
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -29,6 +29,31 @@ async def list_naming_categories():
|
|||||||
return get_naming_categories()
|
return get_naming_categories()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{run_id}/name-suggestions", response_model=list[str])
|
||||||
|
async def get_name_suggestions(
|
||||||
|
run_id: int,
|
||||||
|
count: int = 10,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
run = await session.get(NuzlockeRun, run_id)
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Run not found")
|
||||||
|
|
||||||
|
if not run.naming_scheme:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Collect nicknames already used in this run
|
||||||
|
result = await session.execute(
|
||||||
|
select(Encounter.nickname).where(
|
||||||
|
Encounter.run_id == run_id,
|
||||||
|
Encounter.nickname.isnot(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
used_names = {row[0] for row in result}
|
||||||
|
|
||||||
|
return suggest_names(run.naming_scheme, used_names, count)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=RunResponse, status_code=201)
|
@router.post("", response_model=RunResponse, status_code=201)
|
||||||
async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)):
|
async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)):
|
||||||
# Validate game exists
|
# Validate game exists
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import random
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -16,3 +17,29 @@ def _load_dictionary() -> dict[str, list[str]]:
|
|||||||
def get_naming_categories() -> list[str]:
|
def get_naming_categories() -> list[str]:
|
||||||
"""Return sorted list of available naming category names."""
|
"""Return sorted list of available naming category names."""
|
||||||
return sorted(_load_dictionary().keys())
|
return sorted(_load_dictionary().keys())
|
||||||
|
|
||||||
|
|
||||||
|
def get_words_for_category(category: str) -> list[str]:
|
||||||
|
"""Return the word list for a category, or empty list if not found."""
|
||||||
|
return _load_dictionary().get(category, [])
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_names(
|
||||||
|
category: str,
|
||||||
|
used_names: set[str],
|
||||||
|
count: int = 10,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Pick random name suggestions from a category, excluding used names.
|
||||||
|
|
||||||
|
Returns up to `count` names. If the category is nearly exhausted,
|
||||||
|
returns fewer.
|
||||||
|
"""
|
||||||
|
words = get_words_for_category(category)
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
|
||||||
|
available = [w for w in words if w not in used_names]
|
||||||
|
if not available:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return random.sample(available, min(count, len(available)))
|
||||||
|
|||||||
84
backend/tests/test_naming.py
Normal file
84
backend/tests/test_naming.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.naming import (
|
||||||
|
get_naming_categories,
|
||||||
|
get_words_for_category,
|
||||||
|
suggest_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
MOCK_DICTIONARY = {
|
||||||
|
"mythology": ["Apollo", "Athena", "Loki", "Thor", "Zeus"],
|
||||||
|
"food": ["Basil", "Sage", "Pepper", "Saffron", "Mango"],
|
||||||
|
"space": ["Apollo", "Nova", "Nebula", "Comet", "Vega"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _mock_dictionary():
|
||||||
|
with patch("app.services.naming._load_dictionary", return_value=MOCK_DICTIONARY):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetNamingCategories:
|
||||||
|
def test_returns_sorted_categories(self):
|
||||||
|
result = get_naming_categories()
|
||||||
|
assert result == ["food", "mythology", "space"]
|
||||||
|
|
||||||
|
def test_returns_empty_for_empty_dictionary(self):
|
||||||
|
with patch("app.services.naming._load_dictionary", return_value={}):
|
||||||
|
assert get_naming_categories() == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetWordsForCategory:
|
||||||
|
def test_returns_words_for_valid_category(self):
|
||||||
|
result = get_words_for_category("mythology")
|
||||||
|
assert result == ["Apollo", "Athena", "Loki", "Thor", "Zeus"]
|
||||||
|
|
||||||
|
def test_returns_empty_for_unknown_category(self):
|
||||||
|
assert get_words_for_category("nonexistent") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuggestNames:
|
||||||
|
def test_returns_requested_count(self):
|
||||||
|
result = suggest_names("mythology", set(), count=3)
|
||||||
|
assert len(result) == 3
|
||||||
|
assert all(name in MOCK_DICTIONARY["mythology"] for name in result)
|
||||||
|
|
||||||
|
def test_excludes_used_names(self):
|
||||||
|
used = {"Apollo", "Athena", "Loki"}
|
||||||
|
result = suggest_names("mythology", used, count=10)
|
||||||
|
assert set(result) <= {"Thor", "Zeus"}
|
||||||
|
assert not set(result) & used
|
||||||
|
|
||||||
|
def test_returns_fewer_when_category_nearly_exhausted(self):
|
||||||
|
used = {"Apollo", "Athena", "Loki", "Thor"}
|
||||||
|
result = suggest_names("mythology", used, count=10)
|
||||||
|
assert result == ["Zeus"]
|
||||||
|
|
||||||
|
def test_returns_empty_when_category_fully_exhausted(self):
|
||||||
|
used = {"Apollo", "Athena", "Loki", "Thor", "Zeus"}
|
||||||
|
result = suggest_names("mythology", used, count=10)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_returns_empty_for_unknown_category(self):
|
||||||
|
result = suggest_names("nonexistent", set(), count=10)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_no_duplicates_in_suggestions(self):
|
||||||
|
result = suggest_names("mythology", set(), count=5)
|
||||||
|
assert len(result) == len(set(result))
|
||||||
|
|
||||||
|
def test_default_count_is_ten(self):
|
||||||
|
# food has 5 words, so we should get all 5
|
||||||
|
result = suggest_names("food", set())
|
||||||
|
assert len(result) == 5
|
||||||
|
|
||||||
|
def test_cross_category_names_handled_independently(self):
|
||||||
|
# "Apollo" used in mythology shouldn't affect space
|
||||||
|
used = {"Apollo"}
|
||||||
|
mythology_result = suggest_names("mythology", used, count=10)
|
||||||
|
space_result = suggest_names("space", used, count=10)
|
||||||
|
assert "Apollo" not in mythology_result
|
||||||
|
assert "Apollo" not in space_result
|
||||||
Reference in New Issue
Block a user