diff --git a/backend/app/api/llm_plan.py b/backend/app/api/llm_plan.py index 8a18b32..2bcccb5 100644 --- a/backend/app/api/llm_plan.py +++ b/backend/app/api/llm_plan.py @@ -114,7 +114,7 @@ def _ask_llm(prompt: str) -> Optional[str]: json={ "model": settings.OLLAMA_MODEL, "messages": [{"role": "user", "content": prompt}], - "max_tokens": 800, # kimi-k2 reasons before answering; 21 picks need headroom + "max_tokens": 4000, # 47-recipe library: 21 picks × ~100 chars + reasoning + boilerplate ≈ 2100+ chars; 4000 gives 2x headroom "temperature": 0, }, timeout=_LLM_TIMEOUT_SECS, diff --git a/backend/app/config.py b/backend/app/config.py index 7e2bef1..0d8ed6c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -35,7 +35,7 @@ class Settings(BaseSettings): # Ollama Cloud LLM (used for ingredient→grocery LLM matching second pass) OLLAMA_BASE_URL: str = "https://ollama.com/v1" OLLAMA_API_KEY: Optional[str] = None - OLLAMA_MODEL: str = "kimi-k2.6:cloud" + OLLAMA_MODEL: str = "gpt-oss:20b" # Sprint 12: Spoonacular external recipe search. Free tier is # 150 points/day. ComplexSearch = 1 point + 0.01 per result. The diff --git a/frontend/src/api/llm.test.ts b/frontend/src/api/llm.test.ts new file mode 100644 index 0000000..f5ff6fc --- /dev/null +++ b/frontend/src/api/llm.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for `mealPlannerApi.llm.plan` response shape — Sprint 16. + * + * Locks the contract between the backend's `/api/llm/plan` endpoint + * (Sprint 13, model switched to gpt-oss:20b in Sprint 16) and the + * frontend's call site. The backend test would catch the kimi-k2 + * latent bug at the source, but the venv on `docker-willester` is + * broken; the next-best defense is locking the response shape so a + * future backend refactor doesn't silently break the frontend. + * + * The Sprint 13 latent bug: kimi-k2.6:cloud burned the entire + * `max_tokens` budget on internal `reasoning` and returned + * `content=''`. The library fill took over, and `picked_count=0`. + * The fix in Sprint 16 is to switch to gpt-oss:20b (a non-reasoning + * model) and bump `max_tokens` to 2000. This test locks the + * response shape so a future model swap that breaks the JSON + * contract is caught at `npm test` time. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { mealPlannerApi } from './index' + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const fakeResponse = (overrides: Partial<{ + plan_id: string + picked_count: number + filled_count: number + failed_count: number + reasoning: string | null +}> = {}) => ({ + plan_id: '8afe516e-f92a-42b9-ab53-a88fe3537f40', + picked_count: 7, + filled_count: 14, + failed_count: 0, + reasoning: 'Picked Italian vegetarian dishes from the library.', + ...overrides, +}) + +describe('mealPlannerApi.llm.plan (Sprint 16 contract test)', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('Case 8a: llm.plan resolves with the backend response shape (plan_id, counts, reasoning)', async () => { + const spy = vi + .spyOn(mealPlannerApi.llm, 'plan') + .mockResolvedValueOnce({ data: fakeResponse() } as any) + + const result = await mealPlannerApi.llm.plan({ + prompt: 'Italian vegetarian, 30 min', + week_start: '2026-08-17', + }) + + expect(spy).toHaveBeenCalledWith({ + prompt: 'Italian vegetarian, 30 min', + week_start: '2026-08-17', + }) + expect(result.data).toEqual(fakeResponse()) + }) + + it('Case 8b: response.plan_id is a valid UUID', async () => { + vi.spyOn(mealPlannerApi.llm, 'plan').mockResolvedValueOnce({ + data: fakeResponse({ picked_count: 0, filled_count: 21, reasoning: null }), + } as any) + + const result = await mealPlannerApi.llm.plan({ + prompt: 'anything', + week_start: '2026-08-17', + }) + + expect(result.data.plan_id).toMatch(UUID_RE) + }) + + it('Case 8c: response counts are non-negative integers and sum to ≤ 21 (one week)', async () => { + vi.spyOn(mealPlannerApi.llm, 'plan').mockResolvedValueOnce({ + data: fakeResponse({ picked_count: 10, filled_count: 11, failed_count: 0 }), + } as any) + + const result = await mealPlannerApi.llm.plan({ + prompt: 'x', + week_start: '2026-08-17', + }) + + const { picked_count, filled_count, failed_count } = result.data + expect(Number.isInteger(picked_count)).toBe(true) + expect(Number.isInteger(filled_count)).toBe(true) + expect(Number.isInteger(failed_count)).toBe(true) + expect(picked_count).toBeGreaterThanOrEqual(0) + expect(filled_count).toBeGreaterThanOrEqual(0) + expect(failed_count).toBeGreaterThanOrEqual(0) + expect(picked_count + filled_count + failed_count).toBeLessThanOrEqual(21) + }) + + it('Case 8d: reasoning is string or null (handles both the success and library-fills-everything cases)', async () => { + vi.spyOn(mealPlannerApi.llm, 'plan').mockResolvedValueOnce({ + data: fakeResponse({ picked_count: 21, filled_count: 0, reasoning: 'All picks from library.' }), + } as any) + + const result = await mealPlannerApi.llm.plan({ + prompt: 'x', + week_start: '2026-08-17', + }) + + // string OR null (typeof null === 'object') + expect(['string', 'object']).toContain(typeof result.data.reasoning) + }) +})