Natural-language assistant (seam + closest-to-craft) — Tasks
Status: draft Step 3 output (task half of superpowers:writing-plans). Header, architecture, global constraints, and locked interfaces live in plan.md (approved) — every task's requirements implicitly include plan.md § Global Constraints. Implementation (Step 4) is entered only through plan mode, after the human has reviewed these tasks.
For agentic workers: REQUIRED SUB-SKILL —
superpowers:subagent-driven-development(orsuperpowers:executing-plans) to implement this task-by-task, withsuperpowers:test-driven-developmentinside each task. Steps use- [ ]for tracking.
Run conventions. Commands assume node/pnpm on PATH (this repo's Homebrew node is at /opt/homebrew/bin — prefix it if a non-interactive shell drops it). Single-file test: pnpm exec vitest run <path>. Whole gate: pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm docs:build. Commits are imperative + scoped (api: / web: / specs:). Frequent commits — one per task minimum.
Secrets. The assistant calls Claude with an operator key — separate from the player's GW2 key (the GW2 key stays per-request; this one is ours, server-side). Locally, export ANTHROPIC_API_KEY=sk-... for the T4 dev smoke, the T9 eval, and running the app. On Render, set ANTHROPIC_API_KEY as a service secret; resolveAnthropicKey (T1) fails fast at boot if it is missing, so a mis-provisioned deploy crashes loudly instead of 500-ing per request. Every request spends a small number of Anthropic tokens (Haiku 4.5 ≈ $1/$5 per M in/out; one classification is a few hundred tokens) — that cost is ours, not the player's. Before deploying, set a hard monthly spend limit on the Anthropic API account (Console → Billing → usage limits) so a runaway loop or abuse can never surprise-bill; the feature needs only a small, bounded budget.
Order matters: T1→T6 are backend (each independently testable), T7 regenerates the contract, T8 is the web slice, T9 closes out. The G7 arch guard requires every provider/controller to have a colocated test — that is why T4/T5/T6 each ship one.
Task 1: Anthropic SDK dependency + key resolver
Files:
- Modify:
apps/api/package.json(add@anthropic-ai/sdk) - Create:
apps/api/src/config/anthropic-key.ts - Test:
apps/api/src/config/anthropic-key.test.ts
Interfaces — Produces: resolveAnthropicKey(env: NodeJS.ProcessEnv): string
- [ ] Step 1: Add the dependency
Run: pnpm --filter @gw2priory/api add @anthropic-ai/sdk Expected: apps/api/package.json gains @anthropic-ai/sdk under dependencies; the lockfile updates. (zod is already a dependency — no other package is added. Research F4.)
- [ ] Step 2: Write the failing test
// apps/api/src/config/anthropic-key.test.ts
import { describe, expect, it } from 'vitest';
import { resolveAnthropicKey } from './anthropic-key';
describe('resolveAnthropicKey', () => {
it('returns the trimmed key when set', () => {
const env = { ANTHROPIC_API_KEY: ' sk-abc ' } as NodeJS.ProcessEnv;
expect(resolveAnthropicKey(env)).toBe('sk-abc');
});
it('throws a named error when unset', () => {
expect(() => resolveAnthropicKey({} as NodeJS.ProcessEnv)).toThrow(/ANTHROPIC_API_KEY/);
});
it('throws when blank', () => {
expect(() => resolveAnthropicKey({ ANTHROPIC_API_KEY: ' ' } as NodeJS.ProcessEnv)).toThrow();
});
});- [ ] Step 3: Run it — expect FAIL (
Cannot find module './anthropic-key')
Run: pnpm exec vitest run apps/api/src/config/anthropic-key.test.ts
- [ ] Step 4: Implement (mirrors
config/port.ts)
// apps/api/src/config/anthropic-key.ts
// Read like config/port.ts: a pure resolver over process.env. The AnthropicClient calls this in its
// constructor, so a real server boot with no key throws immediately (fail-fast) — the same posture as
// McpGuard's MCP_AUTH_TOKEN. OpenAPI generation runs in preview mode and never constructs providers,
// so emitting the contract does not need this secret (research V2; generate-openapi.ts).
export function resolveAnthropicKey(env: NodeJS.ProcessEnv): string {
const key = env.ANTHROPIC_API_KEY?.trim();
if (!key) {
throw new Error('ANTHROPIC_API_KEY is not set — the assistant cannot start without it.');
}
return key;
}- [ ] Step 5: Run it — expect PASS; then
pnpm lint - [ ] Step 6: Commit
git add apps/api/package.json pnpm-lock.yaml apps/api/src/config/anthropic-key.ts apps/api/src/config/anthropic-key.test.ts
git commit -m "api: add @anthropic-ai/sdk + ANTHROPIC_API_KEY resolver"Task 2: Contract schemas
Files:
- Create:
apps/api/src/assistant/assistant.schema.ts - Test:
apps/api/src/assistant/assistant.schema.test.ts
Interfaces — Produces: AskRequest, AskRequestDto, AssistantIntent (+ type), AssistantAnswer, AssistantAnswerDto (+ type). Consumes: RankingRow from ../legendaries/ranking.schema.
- [ ] Step 1: Write the failing test
// apps/api/src/assistant/assistant.schema.test.ts
import { describe, expect, it } from 'vitest';
import { AskRequest, AssistantAnswer, AssistantIntent } from './assistant.schema';
describe('AskRequest', () => {
it('accepts a non-empty question', () => {
expect(AskRequest.safeParse({ question: 'hi' }).success).toBe(true);
});
it('rejects empty and whitespace-only', () => {
expect(AskRequest.safeParse({ question: '' }).success).toBe(false);
expect(AskRequest.safeParse({ question: ' ' }).success).toBe(false);
});
it('rejects over-long input', () => {
expect(AskRequest.safeParse({ question: 'x'.repeat(501) }).success).toBe(false);
});
});
describe('AssistantIntent', () => {
it('accepts closest_to_craft', () => {
expect(AssistantIntent.parse({ intent: 'closest_to_craft' }).intent).toBe('closest_to_craft');
});
it('requires a reason on unsupported and rejects unknown intents', () => {
expect(AssistantIntent.safeParse({ intent: 'unsupported' }).success).toBe(false);
expect(AssistantIntent.safeParse({ intent: 'nope' }).success).toBe(false);
});
});
describe('AssistantAnswer', () => {
it('closest_to_craft carries summary + results; unsupported carries a message', () => {
expect(AssistantAnswer.safeParse({ intent: 'closest_to_craft', summary: 's', results: [] }).success).toBe(true);
expect(AssistantAnswer.safeParse({ intent: 'unsupported', message: 'm' }).success).toBe(true);
});
});- [ ] Step 2: Run it — expect FAIL (module missing)
- [ ] Step 3: Implement
// apps/api/src/assistant/assistant.schema.ts
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import { RankingRow } from '../legendaries/ranking.schema';
// `regex(/\S/)` rejects whitespace-only without depending on trim-then-min ordering; the service trims
// before sending to the model.
export const AskRequest = z.object({
question: z.string().min(1).max(500).regex(/\S/, 'question must not be blank'),
});
export class AskRequestDto extends createZodDto(AskRequest) {}
// The ONLY thing the model returns — one structured-output call (spec R3).
export const AssistantIntent = z.discriminatedUnion('intent', [
z.object({ intent: z.literal('closest_to_craft') }),
z.object({ intent: z.literal('unsupported'), reason: z.string() }),
]);
export type AssistantIntent = z.infer<typeof AssistantIntent>;
// What POST /assistant/ask returns — reuses RankingRow, never a parallel copy (spec R6).
export const AssistantAnswer = z.discriminatedUnion('intent', [
z.object({ intent: z.literal('closest_to_craft'), summary: z.string(), results: z.array(RankingRow) }),
z.object({ intent: z.literal('unsupported'), message: z.string() }),
]);
export class AssistantAnswerDto extends createZodDto(AssistantAnswer) {}
export type AssistantAnswer = z.infer<typeof AssistantAnswer>;- [ ] Step 4: Run it — expect PASS;
pnpm lint - [ ] Step 5: Commit
git add apps/api/src/assistant/assistant.schema.ts apps/api/src/assistant/assistant.schema.test.ts
git commit -m "api: add assistant contract schemas (intent + answer union, reusing RankingRow)"Task 3: Deterministic capability (closest-to-craft)
Files:
- Create:
apps/api/src/assistant/closest-to-craft.ts - Test:
apps/api/src/assistant/closest-to-craft.test.ts
Interfaces — Produces: byRemainingGoldAscIdAsc, closestToCraft(rows): RankingRow[], buildSummary(rows): string. Consumes: RankingRow.
- [ ] Step 1: Write the failing test
// apps/api/src/assistant/closest-to-craft.test.ts
import { describe, expect, it } from 'vitest';
import type { RankingRow } from '../legendaries/ranking.schema';
import { buildSummary, byRemainingGoldAscIdAsc, closestToCraft } from './closest-to-craft';
const row = (id: number, myCost: number | null, name = `item-${id}`): RankingRow => ({
id, name, icon: null, subtype: null,
netSell: null, marketCost: null, myCost, personalProfit: null,
gatedInputs: [], craftable: false,
});
describe('byRemainingGoldAscIdAsc', () => {
it('myCost asc, id asc tiebreak, nulls last', () => {
const rows = [row(2, 10), row(1, 10), row(3, null), row(4, 5)];
expect([...rows].sort(byRemainingGoldAscIdAsc).map((r) => r.id)).toEqual([4, 1, 2, 3]);
});
it('both null → id ascending', () => {
expect([row(5, null), row(2, null)].sort(byRemainingGoldAscIdAsc).map((r) => r.id)).toEqual([2, 5]);
});
});
describe('closestToCraft', () => {
it('returns a sorted COPY, least gold first', () => {
const input = [row(1, 100), row(2, 5)];
expect(closestToCraft(input).map((r) => r.id)).toEqual([2, 1]);
expect(input.map((r) => r.id)).toEqual([1, 2]); // original untouched
});
});
describe('buildSummary', () => {
it('names the top row and its remaining gold', () => {
const s = buildSummary(closestToCraft([row(2, 5, 'The Dreamer'), row(1, 100)]));
expect(s).toMatch(/The Dreamer/);
expect(s).toMatch(/5 copper/);
});
it('empty or all-null → nothing-rankable line', () => {
expect(buildSummary([])).toMatch(/couldn't rank/i);
expect(buildSummary([row(1, null)])).toMatch(/couldn't rank/i);
});
});- [ ] Step 2: Run it — expect FAIL
- [ ] Step 3: Implement
// apps/api/src/assistant/closest-to-craft.ts
import type { RankingRow } from '../legendaries/ranking.schema';
// Mirrors ranking.service.ts byPersonalProfitDescIdAsc null-handling: myCost ascending (least gold to
// finish first), id ascending tiebreak, null myCost (incomplete pricing) sorts last. The comparator is
// isolated so a future time/annoyance term extends the key without touching the endpoint (plan R4).
export function byRemainingGoldAscIdAsc(
a: Pick<RankingRow, 'id' | 'myCost'>,
b: Pick<RankingRow, 'id' | 'myCost'>,
): number {
if (a.myCost === null && b.myCost === null) return a.id - b.id;
if (a.myCost === null) return 1;
if (b.myCost === null) return -1;
return a.myCost - b.myCost || a.id - b.id;
}
export function closestToCraft(rows: RankingRow[]): RankingRow[] {
return [...rows].sort(byRemainingGoldAscIdAsc);
}
// Templated from the sorted top row — NO second model call (spec R5). Copper is intentional; the web
// list renders formatted coins, the summary just states the raw remaining cost.
export function buildSummary(rows: RankingRow[]): string {
const top = rows[0];
if (!top || top.myCost === null) {
return "I couldn't rank any legendaries for your account yet.";
}
return `You're closest to crafting ${top.name} — about ${top.myCost} copper of buyable materials to go.`;
}- [ ] Step 4: Run it — expect PASS;
pnpm lint - [ ] Step 5: Commit
git add apps/api/src/assistant/closest-to-craft.ts apps/api/src/assistant/closest-to-craft.test.ts
git commit -m "api: add closest-to-craft comparator + templated summary"Task 4: Anthropic client wrapper
Files:
- Create:
apps/api/src/assistant/anthropic.client.ts - Test:
apps/api/src/assistant/anthropic.client.test.ts
Interfaces — Produces: class AnthropicClient { parseIntent(question: string): Promise<AssistantIntent> }. Consumes: resolveAnthropicKey, AssistantIntent.
Note. This is the only file importing @anthropic-ai/sdk. The constructor builds the SDK (so a real boot without the key fails fast, like McpGuard); OpenAPI generation uses preview: true and never runs it. Its CI test asserts the fail-fast path only — the real model call is exercised by the T9 eval + a one-off dev smoke, and by the mocked service tests in T5.
- [ ] Step 1: Write the failing test (satisfies G7's colocated-test requirement)
// apps/api/src/assistant/anthropic.client.test.ts
import { afterEach, describe, expect, it } from 'vitest';
import { AnthropicClient } from './anthropic.client';
describe('AnthropicClient', () => {
const prev = process.env.ANTHROPIC_API_KEY;
afterEach(() => {
if (prev === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = prev;
});
it('fails fast at construction when ANTHROPIC_API_KEY is absent (mirrors McpGuard)', () => {
delete process.env.ANTHROPIC_API_KEY;
expect(() => new AnthropicClient()).toThrow(/ANTHROPIC_API_KEY/);
});
it('constructs when the key is present', () => {
process.env.ANTHROPIC_API_KEY = 'sk-test';
expect(() => new AnthropicClient()).not.toThrow();
});
});- [ ] Step 2: Run it — expect FAIL
- [ ] Step 3: Implement
// apps/api/src/assistant/anthropic.client.ts
import Anthropic from '@anthropic-ai/sdk';
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod';
import { Injectable } from '@nestjs/common';
import { resolveAnthropicKey } from '../config/anthropic-key';
import { AssistantIntent } from './assistant.schema';
// Haiku 4.5: cheapest, lowest-latency, supports structured output (research F5/V1). Escalate to
// claude-opus-4-8 only if the T9 eval shows the parse is weak on adversarial phrasing.
const MODEL = 'claude-haiku-4-5';
const MAX_TOKENS = 256;
const SYSTEM =
"You classify a Guild Wars 2 player's free-text question. " +
'Choose "closest_to_craft" when they want to know which legendary they are nearest to crafting, in ' +
'any phrasing. Otherwise choose "unsupported" and give a short, friendly reason that names you can ' +
'currently only answer "which legendary am I closest to crafting".';
@Injectable()
export class AnthropicClient {
private readonly anthropic: Anthropic;
constructor() {
// Fail-fast at boot: a real server start with no key throws here. generate-openapi.ts uses
// preview:true, so this constructor never runs during contract generation (research V2).
this.anthropic = new Anthropic({ apiKey: resolveAnthropicKey(process.env) });
}
async parseIntent(question: string): Promise<AssistantIntent> {
const message = await this.anthropic.messages.parse({
model: MODEL,
max_tokens: MAX_TOKENS,
system: SYSTEM,
output_config: { format: zodOutputFormat(AssistantIntent) },
messages: [{ role: 'user', content: question }],
});
// parsed_output is null if the model refused or the output failed schema validation — degrade to a
// graceful decline rather than throwing (spec R3, P2 honesty).
return message.parsed_output ?? { intent: 'unsupported', reason: "I couldn't understand that request." };
}
}- [ ] Step 4: Run it — expect PASS;
pnpm typecheck(confirms the SDK'smessages.parse/zodOutputFormattypes line up). If the SDK'sparse/zodOutputFormatimport path differs on the installed version, fix pernode_modules/@anthropic-ai/sdk— do not invent one. - [ ] Step 5: Dev smoke (not committed, not CI). With a real key exported, run one throwaway parse to confirm
parsed_outputvalidates end-to-end, then discard:
ANTHROPIC_API_KEY=sk-... node -e "require('@swc/register'); (async()=>{const {AnthropicClient}=require('./apps/api/src/assistant/anthropic.client.ts'); console.log(await new AnthropicClient().parseIntent('which shiny am I nearly done with?'));})()"Expected: { intent: 'closest_to_craft' }. (If @swc/register is unavailable, defer this smoke to after T7's build and run against dist/.)
- [ ] Step 6: Commit
git add apps/api/src/assistant/anthropic.client.ts apps/api/src/assistant/anthropic.client.test.ts
git commit -m "api: add Anthropic client wrapper (structured intent parse, fail-fast key)"Task 5: Assistant service (orchestration)
Files:
- Create:
apps/api/src/assistant/assistant.service.ts - Test:
apps/api/src/assistant/assistant.service.test.ts
Interfaces — Produces: class AssistantService { ask(question, apiKey): Promise<AssistantAnswer> }. Consumes: AnthropicClient.parseIntent, RankingService.rank, closestToCraft, buildSummary.
- [ ] Step 1: Write the failing test (client + ranking mocked by plain instantiation — the repo idiom)
// apps/api/src/assistant/assistant.service.test.ts
import { describe, expect, it, vi } from 'vitest';
import type { RankingRow } from '../legendaries/ranking.schema';
import type { RankingService } from '../legendaries/ranking.service';
import type { AnthropicClient } from './anthropic.client';
import { AssistantService } from './assistant.service';
const row = (id: number, myCost: number | null, name = `item-${id}`): RankingRow => ({
id, name, icon: null, subtype: null,
netSell: null, marketCost: null, myCost, personalProfit: null,
gatedInputs: [], craftable: false,
});
const build = (parseIntent: AnthropicClient['parseIntent'], rank: RankingService['rank'] = vi.fn()) => {
const anthropic = { parseIntent: vi.fn(parseIntent) };
const ranking = { rank: vi.fn(rank) };
const svc = new AssistantService(anthropic as never, ranking as never);
return { svc, anthropic, ranking };
};
describe('AssistantService.ask', () => {
it('P1 #1/SC1: closest_to_craft orders results by myCost asc, null last', async () => {
const { svc } = build(async () => ({ intent: 'closest_to_craft' }), async () => [row(1, 100), row(2, 5), row(3, null)]);
const a = await svc.ask('which shiny am I nearly done with?', 'key');
if (a.intent !== 'closest_to_craft') throw new Error('unreachable');
expect(a.results.map((r) => r.id)).toEqual([2, 1, 3]);
});
it('P1 #3/SC6: summary is templated from the top row and exactly ONE model call is made', async () => {
const { svc, anthropic } = build(async () => ({ intent: 'closest_to_craft' }), async () => [row(2, 5, 'The Dreamer')]);
const a = await svc.ask('q', 'key');
if (a.intent !== 'closest_to_craft') throw new Error('unreachable');
expect(a.summary).toMatch(/The Dreamer/);
expect(anthropic.parseIntent).toHaveBeenCalledTimes(1);
});
it('P1 #3: empty ranking → nothing-rankable summary, no crash', async () => {
const { svc } = build(async () => ({ intent: 'closest_to_craft' }), async () => []);
const a = await svc.ask('q', 'key');
if (a.intent !== 'closest_to_craft') throw new Error('unreachable');
expect(a.summary).toMatch(/couldn't rank/i);
expect(a.results).toEqual([]);
});
it('P2 #1/SC2: unsupported returns a message and no results', async () => {
const { svc } = build(async () => ({ intent: 'unsupported', reason: 'no mastery data' }));
expect(await svc.ask('where is this mastery point?', 'key')).toEqual({ intent: 'unsupported', message: 'no mastery data' });
});
it('P2 #3/SC2: unsupported does NOT invoke RankingService', async () => {
const { svc, ranking } = build(async () => ({ intent: 'unsupported', reason: 'r' }));
await svc.ask('q', 'key');
expect(ranking.rank).not.toHaveBeenCalled();
});
});- [ ] Step 2: Run it — expect FAIL
- [ ] Step 3: Implement
// apps/api/src/assistant/assistant.service.ts
import { Injectable } from '@nestjs/common';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference (G2).
import { RankingService } from '../legendaries/ranking.service';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference (G2).
import { AnthropicClient } from './anthropic.client';
import type { AssistantAnswer } from './assistant.schema';
import { buildSummary, closestToCraft } from './closest-to-craft';
@Injectable()
export class AssistantService {
constructor(
private readonly anthropic: AnthropicClient,
private readonly ranking: RankingService,
) {}
async ask(question: string, apiKey: string): Promise<AssistantAnswer> {
const intent = await this.anthropic.parseIntent(question.trim());
if (intent.intent === 'unsupported') {
return {
intent: 'unsupported',
message: intent.reason || 'I can only answer "which legendary am I closest to crafting" for now.',
};
}
const rows = closestToCraft(await this.ranking.rank(apiKey));
return { intent: 'closest_to_craft', summary: buildSummary(rows), results: rows };
}
}- [ ] Step 4: Run it — expect PASS;
pnpm lint(confirms the G2 value-import ignores are correct) - [ ] Step 5: Commit
git add apps/api/src/assistant/assistant.service.ts apps/api/src/assistant/assistant.service.test.ts
git commit -m "api: add AssistantService (parse -> route -> envelope, one model call)"Task 6: Controller + module wiring
Files:
- Create:
apps/api/src/assistant/assistant.controller.ts - Create:
apps/api/src/assistant/assistant.module.ts - Create:
apps/api/src/assistant/assistant.module.test.ts - Modify:
apps/api/src/app.module.ts - Test:
apps/api/src/assistant/assistant.controller.test.ts - Verify:
apps/api/src/legendaries/legendaries.module.tsalreadyexports: [..., RankingService](it does — confirm, no edit expected).
Interfaces — Consumes: AssistantService.ask, AskRequestDto, AssistantAnswerDto, Gw2UnauthorizedError, Gw2ForbiddenError.
- [ ] Step 1: Write the failing controller test (mirrors
account.controller.test.ts)
// apps/api/src/assistant/assistant.controller.test.ts
import { BadGatewayException, BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { Gw2ForbiddenError, Gw2UnauthorizedError } from '../gw2/gw2.errors';
import { AssistantController } from './assistant.controller';
import type { AssistantService } from './assistant.service';
const build = (ask: AssistantService['ask']) =>
new AssistantController({ ask } as unknown as AssistantService);
describe('AssistantController — POST /assistant/ask', () => {
it('valid bearer delegates the question + key to the service', async () => {
const ask = vi.fn(async () => ({ intent: 'unsupported', message: 'm' }) as const);
await expect(build(ask).ask({ question: 'hi' } as never, 'Bearer abc')).resolves.toEqual({ intent: 'unsupported', message: 'm' });
expect(ask).toHaveBeenCalledWith('hi', 'abc');
});
it('C2/SC4: missing Authorization → 400, service not called', async () => {
const ask = vi.fn();
await expect(build(ask as never).ask({ question: 'hi' } as never, undefined)).rejects.toBeInstanceOf(BadRequestException);
expect(ask).not.toHaveBeenCalled();
});
it('C2/SC4: blank bearer → 400', async () => {
await expect(build(vi.fn() as never).ask({ question: 'hi' } as never, 'Bearer ')).rejects.toBeInstanceOf(BadRequestException);
});
it('SC4: Gw2UnauthorizedError → 401', async () => {
const c = build(async () => { throw new Gw2UnauthorizedError('invalid or expired key'); });
await expect(c.ask({ question: 'hi' } as never, 'Bearer abc')).rejects.toBeInstanceOf(UnauthorizedException);
});
it('SC4: Gw2ForbiddenError → 403 naming the scope', async () => {
const c = build(async () => { throw new Gw2ForbiddenError('inventories'); });
const r = c.ask({ question: 'hi' } as never, 'Bearer abc');
await expect(r).rejects.toBeInstanceOf(ForbiddenException);
await expect(r).rejects.toThrow('inventories');
});
it('SC4: any other error (e.g. an LLM failure) → 502', async () => {
const c = build(async () => { throw new Error('anthropic down'); });
await expect(c.ask({ question: 'hi' } as never, 'Bearer abc')).rejects.toBeInstanceOf(BadGatewayException);
});
});- [ ] Step 2: Run it — expect FAIL
- [ ] Step 3: Implement the controller (Bearer + error map copied from
legendaries.controller.ts; HTTP exceptions live only here — G5)
// apps/api/src/assistant/assistant.controller.ts
import {
BadGatewayException, BadRequestException, Body, Controller, ForbiddenException,
Headers, Post, UnauthorizedException,
} from '@nestjs/common';
import { ZodResponse } from 'nestjs-zod';
import { Gw2ForbiddenError, Gw2UnauthorizedError } from '../gw2/gw2.errors';
import type { AssistantAnswer } from './assistant.schema';
// AskRequestDto must be a VALUE import: the global ZodValidationPipe reads it as the body's runtime
// metatype to validate. `import type` would silently disable validation (same trap as G2).
// biome-ignore lint/style/useImportType: value import — ZodValidationPipe reads the metatype.
import { AskRequestDto, AssistantAnswerDto } from './assistant.schema';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference (G2).
import { AssistantService } from './assistant.service';
@Controller('assistant')
export class AssistantController {
constructor(private readonly assistant: AssistantService) {}
@Post('ask')
@ZodResponse({ status: 200, type: AssistantAnswerDto })
async ask(
@Body() body: AskRequestDto,
@Headers('authorization') authorization?: string,
): Promise<AssistantAnswer> {
const key = this.requireBearer(authorization);
try {
return await this.assistant.ask(body.question, key);
} catch (e) {
this.mapError(e);
}
}
/** `^Bearer (.+)$` trim; missing or blank -> 400. Never logs the token. */
private requireBearer(authorization?: string): string {
const key = authorization?.match(/^Bearer (.+)$/)?.[1]?.trim();
if (!key) throw new BadRequestException('missing or malformed Authorization header');
return key;
}
/** GW2 failures map like the ranking route; anything else (incl. LLM/provider errors) -> 502. */
private mapError(e: unknown): never {
if (e instanceof Gw2UnauthorizedError) throw new UnauthorizedException('invalid or expired key');
if (e instanceof Gw2ForbiddenError) throw new ForbiddenException(`key missing the ${e.scope} scope`);
throw new BadGatewayException('GW2 upstream error');
}
}[ ] Step 4: Run the controller test — expect PASS
[ ] Step 5: Write the failing module test (mirrors
legendaries.module.test.ts; sets the key because compiling the module constructsAnthropicClient)
// apps/api/src/assistant/assistant.module.test.ts
import { Test } from '@nestjs/testing';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AssistantController } from './assistant.controller';
import { AssistantModule } from './assistant.module';
import { AssistantService } from './assistant.service';
describe('AssistantModule', () => {
const prev = process.env.ANTHROPIC_API_KEY;
beforeAll(() => { process.env.ANTHROPIC_API_KEY = 'sk-test'; }); // AnthropicClient constructs at DI time
afterAll(() => { if (prev === undefined) delete process.env.ANTHROPIC_API_KEY; else process.env.ANTHROPIC_API_KEY = prev; });
it('builds its DI graph (RankingService resolves via the LegendariesModule import)', async () => {
const moduleRef = await Test.createTestingModule({ imports: [AssistantModule] }).compile();
expect(moduleRef.get(AssistantService)).toBeInstanceOf(AssistantService);
expect(moduleRef.get(AssistantController)).toBeInstanceOf(AssistantController);
});
});- [ ] Step 6: Run it — expect FAIL; then implement the module + register it
// apps/api/src/assistant/assistant.module.ts
import { Module } from '@nestjs/common';
import { LegendariesModule } from '../legendaries/legendaries.module'; // exports RankingService
import { AnthropicClient } from './anthropic.client';
import { AssistantController } from './assistant.controller';
import { AssistantService } from './assistant.service';
@Module({
imports: [LegendariesModule],
controllers: [AssistantController],
providers: [AnthropicClient, AssistantService],
})
export class AssistantModule {}// apps/api/src/app.module.ts — add the import (app.module stays imports-only, G6)
import { AssistantModule } from './assistant/assistant.module';
// ...add `AssistantModule,` to the @Module({ imports: [...] }) array.- [ ] Step 7: Run the module test — expect PASS; then the arch guards + full api suite:
Run: pnpm exec vitest run apps/api/src/conventions/conventions.arch.test.ts apps/api/src/assistant Expected: PASS (G2 value-imports, G5 exceptions-in-controller, G6 module structure, G7 colocated tests all green for the new folder).
- [ ] Step 8: Commit
git add apps/api/src/assistant/assistant.controller.ts apps/api/src/assistant/assistant.controller.test.ts \
apps/api/src/assistant/assistant.module.ts apps/api/src/assistant/assistant.module.test.ts apps/api/src/app.module.ts
git commit -m "api: wire POST /api/assistant/ask (controller + module)"Task 7: Regenerate the contract (OpenAPI + Orval)
Files:
Regenerate:
apps/api/openapi.jsonRegenerate:
apps/web/src/api/generated/**(newendpoints/assistant/*+models/+models-zod/)Possibly modify:
apps/api/src/generate-openapi.test.ts(only if it asserts a path inventory)[ ] Step 1: Build the api, then regenerate both artifacts (mirrors the
verify:contractpipeline)
pnpm --filter @gw2priory/api build
pnpm --filter @gw2priory/api generate:openapi # rewrites apps/api/openapi.json with POST /api/assistant/ask
pnpm --filter @gw2priory/web generate:api # orval rewrites the generated client
pnpm format[ ] Step 2: Confirm the new operation appears in
apps/api/openapi.json(path"/api/assistant/ask", apostwith theAssistantAnswerresponse schema) and thatapps/web/src/api/generated/endpoints/assistant/now exists.[ ] Step 3: If
generate-openapi.test.tsfails because it asserts a fixed set of paths/operations, add/api/assistant/askto its expectation. Run:pnpm exec vitest run apps/api/src/generate-openapi.test.ts→ PASS.[ ] Step 4: Prove the contract is in sync (the exact CI check):
Run: pnpm verify:contract Expected: exit 0 (no diff) — regeneration is idempotent.
- [ ] Step 5: Commit
git add apps/api/openapi.json apps/web/src/api/generated
git add apps/api/src/generate-openapi.test.ts 2>/dev/null || true
git commit -m "api,web: regenerate OpenAPI + Orval client for /assistant/ask"Task 8: Minimal web feature
Files:
- Create:
apps/web/src/api/useAskAssistant.ts(facade mutation hook) - Modify:
apps/web/src/api/index.ts(exportuseAskAssistant) - Create:
apps/web/src/features/assistant/AssistantView.tsx - Create:
apps/web/src/features/assistant/AssistantPage.tsx - Create:
apps/web/src/features/assistant/routes.tsx - Create:
apps/web/src/features/assistant/styles.ts(PandaCSS — no literals, no new tokens) - Modify:
apps/web/src/main.tsx(register the assistant route, mirroring the other features) - Modify:
apps/web/src/App.tsx(add anAssistantNavLink) - Test:
apps/web/src/features/assistant/__tests__/AssistantView.test.tsx - Test:
apps/web/src/features/assistant/__tests__/AssistantPage.test.tsx
Interfaces — Produces: useAskAssistant(apiKey: string) returning a TanStack useMutation result whose mutateAsync(question) resolves to AssistantAnswer; AssistantView, AssistantPage. Consumes: the generated assistantControllerAsk request fn + AssistantControllerAskResponse zod (names mirror the ranking hook — confirm against the file T7 generated), useApiKey, ConnectAccountPrompt, Coins.
Note. This is the app's first mutation (all existing hooks are Suspense queries). Mirror useLegendaryRanking.ts for the header/status/parse shape; use useMutation instead of useSuspenseQuery. After T7, open generated/endpoints/assistant/assistant.ts and use the exact exported request-fn name orval emitted — the code below assumes assistantControllerAsk.
- [ ] Step 1: Write the facade mutation hook
// apps/web/src/api/useAskAssistant.ts
import { useMutation } from '@tanstack/react-query';
import type { z } from 'zod';
import { assistantControllerAsk } from './generated/endpoints/assistant/assistant';
import { AssistantControllerAskResponse } from './generated/endpoints/assistant/assistant.zod';
import { MissingScopeError } from './MissingScopeError';
export type AssistantAnswer = z.infer<typeof AssistantControllerAskResponse>;
// The key is sent per-request as an Authorization header (never persisted). apiFetch resolves
// { data, status, headers } for any status (mirrors useLegendaryRanking): read status first so a real
// 403 throws the typed error instead of an opaque ZodError, then parse the 200 body.
export function useAskAssistant(apiKey: string) {
return useMutation({
mutationFn: async (question: string): Promise<AssistantAnswer> => {
const res = await assistantControllerAsk(
{ question },
{ request: { headers: { Authorization: `Bearer ${apiKey}` } } },
);
const status: number = res.status;
if (status === 403) throw MissingScopeError.fromBody(res.data);
return AssistantControllerAskResponse.parse(res.data);
},
});
}[ ] Step 2: Export it from
apps/web/src/api/index.ts(addexport { useAskAssistant } from './useAskAssistant';).[ ] Step 3: Write the failing view test (facade mocked, mirrors
AccountPage.test.tsx)
// apps/web/src/features/assistant/__tests__/AssistantView.test.tsx
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as api from '../../../api';
import { AssistantView } from '../AssistantView';
afterEach(() => vi.restoreAllMocks());
function ask(q: string) {
fireEvent.change(screen.getByRole('textbox'), { target: { value: q } });
fireEvent.click(screen.getByRole('button', { name: /ask/i }));
}
describe('AssistantView', () => {
it('P1 #2/SC7: renders the summary + a plain results list', () => {
const mutateAsync = vi.fn();
vi.spyOn(api, 'useAskAssistant').mockReturnValue({
mutateAsync, isPending: false,
data: { intent: 'closest_to_craft', summary: 'You are closest to The Dreamer.',
results: [{ id: 30685, name: 'The Dreamer', icon: null, subtype: null, netSell: null,
marketCost: null, myCost: 500, personalProfit: null, gatedInputs: [], craftable: false }] },
} as never);
render(<AssistantView apiKey="k" />);
expect(screen.getByText(/closest to The Dreamer/i)).toBeInTheDocument();
expect(screen.getByText('The Dreamer')).toBeInTheDocument();
});
it('P2/SC7: renders an unsupported decline message', () => {
vi.spyOn(api, 'useAskAssistant').mockReturnValue({
mutateAsync: vi.fn(), isPending: false,
data: { intent: 'unsupported', message: 'I can only answer closest-to-craft for now.' },
} as never);
render(<AssistantView apiKey="k" />);
expect(screen.getByText(/only answer closest-to-craft/i)).toBeInTheDocument();
});
it('submitting a question calls the mutation', () => {
const mutateAsync = vi.fn();
vi.spyOn(api, 'useAskAssistant').mockReturnValue({ mutateAsync, isPending: false, data: undefined } as never);
render(<AssistantView apiKey="k" />);
ask('which shiny am I nearly done with?');
expect(mutateAsync).toHaveBeenCalledWith('which shiny am I nearly done with?');
});
});- [ ] Step 4: Run it — expect FAIL; implement the view + styles
// apps/web/src/features/assistant/styles.ts
import { css } from '../../../styled-system/css'; // Panda entrypoint — match the path other features use
export const formStyles = css({ display: 'flex', gap: '2', marginBottom: '4' });
export const inputStyles = css({ flex: '1', padding: '2', borderWidth: '1px', borderColor: 'border', borderRadius: 'md' });
export const summaryStyles = css({ fontWeight: 'medium', marginBottom: '3' });
export const listStyles = css({ display: 'flex', flexDirection: 'column', gap: '2' });
export const rowStyles = css({ display: 'flex', justifyContent: 'space-between', gap: '4' });// apps/web/src/features/assistant/AssistantView.tsx
import { type FormEvent, useState } from 'react';
import { useAskAssistant } from '../../api';
import { Coins } from '../../shared/ui/Coins';
import { formStyles, inputStyles, listStyles, rowStyles, summaryStyles } from './styles';
export function AssistantView({ apiKey }: { apiKey: string }) {
const ask = useAskAssistant(apiKey);
const [question, setQuestion] = useState('');
function onSubmit(e: FormEvent) {
e.preventDefault();
const q = question.trim();
if (q) void ask.mutateAsync(q);
}
const answer = ask.data;
return (
<section>
<form className={formStyles} onSubmit={onSubmit}>
<input
className={inputStyles}
aria-label="Ask about your legendaries"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Which legendary am I closest to crafting?"
/>
<button type="submit" disabled={ask.isPending}>Ask</button>
</form>
{answer?.intent === 'unsupported' && <p role="status">{answer.message}</p>}
{answer?.intent === 'closest_to_craft' && (
<>
<p className={summaryStyles}>{answer.summary}</p>
<ul className={listStyles}>
{answer.results.map((r) => (
<li key={r.id} className={rowStyles}>
<span>{r.name}</span>
{r.myCost !== null && <Coins value={r.myCost} />}
</li>
))}
</ul>
</>
)}
</section>
);
}Confirm the Panda
cssimport path and theCoinsprop name againstCoins.tsx/ an existing feature'sstyles.tsbefore finalizing — adjust to the repo's exact entrypoint. Use tokens only (no literal colors — the tokens guard scans comments too).
[ ] Step 5: Run the view test — expect PASS
[ ] Step 6: Write the page test (key gate)
// apps/web/src/features/assistant/__tests__/AssistantPage.test.tsx
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as apiKeyMod from '../../../shared/lib/useApiKey';
import { AssistantPage } from '../AssistantPage';
afterEach(() => vi.restoreAllMocks());
describe('AssistantPage', () => {
it('SC7: no stored key → ConnectAccountPrompt, no assistant UI', () => {
vi.spyOn(apiKeyMod, 'useApiKey').mockReturnValue(null);
render(<AssistantPage />);
expect(screen.getByText(/connect your gw2 account/i)).toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
it('SC7: with a key → the assistant input renders', () => {
vi.spyOn(apiKeyMod, 'useApiKey').mockReturnValue('stored-key');
vi.spyOn(require('../../../api'), 'useAskAssistant').mockReturnValue({ mutateAsync: vi.fn(), isPending: false, data: undefined } as never);
render(<AssistantPage />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
});- [ ] Step 7: Run it — expect FAIL; implement the page + route + nav
// apps/web/src/features/assistant/AssistantPage.tsx
import { useApiKey } from '../../shared/lib/useApiKey';
import { ConnectAccountPrompt } from '../../shared/ui/ConnectAccountPrompt';
import { AssistantView } from './AssistantView';
export function AssistantPage() {
const apiKey = useApiKey();
if (apiKey === null) return <ConnectAccountPrompt message="to ask about your legendaries" />;
return <AssistantView apiKey={apiKey} />;
}// apps/web/src/features/assistant/routes.tsx
import type { RouteObject } from 'react-router';
import { AssistantPage } from './AssistantPage';
export const routes: RouteObject[] = [{ path: '/assistant', element: <AssistantPage /> }];// apps/web/src/main.tsx — register the feature's routes alongside the others (mirror the existing
// `routes as legendariesRoutes` / account-routes registration; add:)
// import { routes as assistantRoutes } from './features/assistant/routes';
// ...spread `...assistantRoutes` into the same children/array the other feature route tables use.
// apps/web/src/App.tsx — add a nav entry after the existing NavLinks:
// <NavLink to="/assistant" className={({ isActive }) => navItem({ active: isActive })}>Assistant</NavLink>- [ ] Step 8: Run both feature tests — expect PASS; then the web build (React Compiler gate) + guards:
Run: pnpm --filter @gw2priory/web build (catches destructured-default/inline-type prop issues) Run: pnpm exec vitest run apps/web/src/__tests__ (tokens-never-literals + conventions guards stay green — no new tokens, no literal colors)
- [ ] Step 9: Commit
git add apps/web/src/api/useAskAssistant.ts apps/web/src/api/index.ts apps/web/src/features/assistant apps/web/src/main.tsx apps/web/src/App.tsx
git commit -m "web: add minimal assistant page (free-text box, abstract render, key gate)"Task 9: Eval + full verification
Files:
Create:
apps/api/src/assistant/eval/intent.eval.ts(excluded from CI — filename ends.eval.ts, not.test.ts)Modify:
specs/029-nl-assistant/spec.md(fill the traceability table with the real test names)[ ] Step 1: Write the NLU eval (a script, run on demand with a real key — C1: eval, not a CI gate)
// apps/api/src/assistant/eval/intent.eval.ts
import { AnthropicClient } from '../anthropic.client';
// Positive phrasings the end user might actually type — plain, slang, GW2 jargon, indirect. Grow this
// list as real users surface new wordings. The NEAR-MISS negatives (profit, how-to, most-profitable-to-
// sell, wallet) are the important cases: they prove the classifier does not OVER-fire on adjacent GW2
// questions. Borderline flips are a judgement call for the T4 system prompt, not a hard failure (C1).
const CASES: Array<{ q: string; expect: 'closest_to_craft' | 'unsupported' }> = [
// closest_to_craft — one intent, many surfaces
{ q: 'which legendary am I closest to crafting?', expect: 'closest_to_craft' },
{ q: 'which shiny am I nearly done with?', expect: 'closest_to_craft' },
{ q: 'what legendary do I have the most mats for?', expect: 'closest_to_craft' },
{ q: 'am I close to finishing any legendary?', expect: 'closest_to_craft' },
{ q: "what's the least gold I need to finish a legendary?", expect: 'closest_to_craft' },
{ q: 'which leggy should I finish next?', expect: 'closest_to_craft' },
{ q: 'how close am I to any legendary weapon?', expect: 'closest_to_craft' },
{ q: "what's the cheapest legendary for me to complete right now?", expect: 'closest_to_craft' },
{ q: 'which legendary needs the fewest mats from me?', expect: 'closest_to_craft' },
{ q: "gimme the legendary I'm nearest to making", expect: 'closest_to_craft' },
{ q: 'what should I craft next — which legendary is closest?', expect: 'closest_to_craft' },
{ q: 'closest legendary?', expect: 'closest_to_craft' },
// unsupported — off-topic AND near-misses (must NOT come back as closest_to_craft)
{ q: 'where can I get this mastery point?', expect: 'unsupported' },
{ q: 'is Bifrost profitable right now?', expect: 'unsupported' },
{ q: 'which legendary is the most profitable to sell?', expect: 'unsupported' },
{ q: 'how do I make Twilight?', expect: 'unsupported' },
{ q: 'how much gold do I have?', expect: 'unsupported' },
{ q: 'what is the weather today?', expect: 'unsupported' },
];
export async function runIntentEval(): Promise<void> {
const client = new AnthropicClient(); // needs ANTHROPIC_API_KEY
let pass = 0;
for (const c of CASES) {
const got = (await client.parseIntent(c.q)).intent;
const ok = got === c.expect;
pass += ok ? 1 : 0;
console.log(`${ok ? 'PASS' : 'FAIL'} [${got}] ${c.q}`);
}
console.log(`\n${pass}/${CASES.length} passed`);
}
if (require.main === module) void runIntentEval();- [ ] Step 2: Run the eval once (manual, needs a key; records parse quality — not committed output):
pnpm --filter @gw2priory/api build && ANTHROPIC_API_KEY=sk-... node apps/api/dist/assistant/eval/intent.eval.jsExpected: all ~18 cases correct (or close). Positives should all classify closest_to_craft; the near-miss negatives (profit / how-to / most-profitable / wallet) must stay unsupported. If several phrasings fail, revisit the T4 system prompt (add an example or two) or escalate the model to claude-opus-4-8 (F5) before proceeding.
[ ] Step 3: Fill the spec's traceability table — replace each empty cell with the real test name (they now exist): P1 #1/#3/#4 & P2 #1/#3 →
assistant.service.test.ts; SC3 →closest-to-craft.test.ts; SC4 →assistant.controller.test.ts; SC5 →assistant.schema.test.ts+ generated-client presence; SC6 →assistant.service.test.ts("one model call"); SC7 →AssistantView.test.tsx+AssistantPage.test.tsx; SC1/SC2 →assistant.service.test.ts(mocked) +intent.eval.ts.[ ] Step 4: Run the whole gate
Run: pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm docs:build && pnpm verify:contract Expected: all PASS, no diff (SC8).
- [ ] Step 5: Commit
git add apps/api/src/assistant/eval/intent.eval.ts specs/029-nl-assistant/spec.md
git commit -m "api: add intent eval; specs: complete 029 traceability"Self-review (plan/spec vs tasks)
- Coverage: R1→T6, R2/C2→T6, R3→T4/T5, R4/R5→T3/T5, R6→T2/T7, R7→T8, R8→T1/T4, R9→T4/T5/T9, R10→T9; C1→T9; SC1/2→T5+T9, SC3→T3, SC4→T6, SC5→T2/T7, SC6→T5, SC7→T8, SC8→T9. No orphan.
- G-guards: G2 value-imports (T5/T6 biome-ignores), G5 exceptions-only-in-controller (T6), G6 module + imports-only app.module (T6), G7 colocated tests (T4 client, T5 service, T6 controller). Covered.
- Type consistency:
AssistantIntent/AssistantAnswer/parseIntent/closestToCraft/buildSummary/ask/useAskAssistantused with identical names + signatures across T2–T8 (see plan Locked interfaces). - Codegen boundary (honest): the exact Orval export names (
assistantControllerAsk,AssistantControllerAskResponse) are produced by T7 and confirmed against the generated file in T8 — the hook code is complete; only the import identifier may need aligning to orval's emitted casing.