From 2ac6c23577a39a740cf2d6de851c45e113536f89 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Wed, 11 Feb 2026 21:45:04 +0100 Subject: [PATCH] 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 --- ...cker-c6ly--build-name-suggestion-engine.md | 14 ++-- .../nuzlocke-tracker-igl3--name-generation.md | 4 +- backend/src/app/api/runs.py | 27 +++++- backend/src/app/services/naming.py | 27 ++++++ backend/tests/test_naming.py | 84 +++++++++++++++++++ 5 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 backend/tests/test_naming.py diff --git a/.beans/nuzlocke-tracker-c6ly--build-name-suggestion-engine.md b/.beans/nuzlocke-tracker-c6ly--build-name-suggestion-engine.md index 7529b85..e4f8ed6 100644 --- a/.beans/nuzlocke-tracker-c6ly--build-name-suggestion-engine.md +++ b/.beans/nuzlocke-tracker-c6ly--build-name-suggestion-engine.md @@ -1,11 +1,11 @@ --- # nuzlocke-tracker-c6ly title: Build name suggestion engine -status: todo +status: completed type: task priority: normal 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 blocking: - nuzlocke-tracker-bi4e @@ -29,8 +29,8 @@ Build the backend service and API endpoint that picks random name suggestions fr ## Checklist -- [ ] Create a service module for name suggestion logic (e.g. `services/name_suggestions.py`) -- [ ] Implement dictionary loading with in-memory caching -- [ ] Implement random selection from a category with exclusion of used names -- [ ] Add API endpoint for fetching suggestions -- [ ] Add unit tests for the suggestion logic \ No newline at end of file +- [x] Create a service module for name suggestion logic (e.g. `services/name_suggestions.py`) +- [x] Implement dictionary loading with in-memory caching +- [x] Implement random selection from a category with exclusion of used names +- [x] Add API endpoint for fetching suggestions +- [x] Add unit tests for the suggestion logic \ No newline at end of file diff --git a/.beans/nuzlocke-tracker-igl3--name-generation.md b/.beans/nuzlocke-tracker-igl3--name-generation.md index a4d739c..a6c3cff 100644 --- a/.beans/nuzlocke-tracker-igl3--name-generation.md +++ b/.beans/nuzlocke-tracker-igl3--name-generation.md @@ -5,7 +5,7 @@ status: todo type: epic priority: normal 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. @@ -28,7 +28,7 @@ Implement a dictionary-based nickname generation system for Nuzlocke runs. Inste ## Success Criteria - [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 - [ ] User can regenerate suggestions if none fit - [x] User can select a naming scheme per run \ No newline at end of file diff --git a/backend/src/app/api/runs.py b/backend/src/app/api/runs.py index a63e296..69ccea0 100644 --- a/backend/src/app/api/runs.py +++ b/backend/src/app/api/runs.py @@ -19,7 +19,7 @@ from app.schemas.run import ( RunResponse, RunUpdate, ) -from app.services.naming import get_naming_categories +from app.services.naming import get_naming_categories, suggest_names router = APIRouter() @@ -29,6 +29,31 @@ async def list_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) async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)): # Validate game exists diff --git a/backend/src/app/services/naming.py b/backend/src/app/services/naming.py index 42d9305..83a0533 100644 --- a/backend/src/app/services/naming.py +++ b/backend/src/app/services/naming.py @@ -1,4 +1,5 @@ import json +import random from functools import lru_cache from pathlib import Path @@ -16,3 +17,29 @@ def _load_dictionary() -> dict[str, list[str]]: def get_naming_categories() -> list[str]: """Return sorted list of available naming category names.""" 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))) diff --git a/backend/tests/test_naming.py b/backend/tests/test_naming.py new file mode 100644 index 0000000..df60250 --- /dev/null +++ b/backend/tests/test_naming.py @@ -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