Skip to content

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 (or superpowers:executing-plans) to implement this task-by-task, with superpowers:test-driven-development inside 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
ts
// 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)
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
bash
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
ts
// 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
ts
// 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
bash
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
ts
// 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
ts
// 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
bash
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)
ts
// 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
ts
// 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's messages.parse / zodOutputFormat types line up). If the SDK's parse/zodOutputFormat import path differs on the installed version, fix per node_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_output validates end-to-end, then discard:
bash
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
bash
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)
ts
// 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
ts
// 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
bash
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.ts already exports: [..., 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)
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)
ts
// 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 constructs AnthropicClient)

ts
// 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
ts
// 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 {}
ts
// 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
bash
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.json

  • Regenerate: apps/web/src/api/generated/** (new endpoints/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:contract pipeline)

bash
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", a post with the AssistantAnswer response schema) and that apps/web/src/api/generated/endpoints/assistant/ now exists.

  • [ ] Step 3: If generate-openapi.test.ts fails because it asserts a fixed set of paths/operations, add /api/assistant/ask to 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
bash
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 (export useAskAssistant)
  • 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 an Assistant NavLink)
  • 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
ts
// 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 (add export { useAskAssistant } from './useAskAssistant';).

  • [ ] Step 3: Write the failing view test (facade mocked, mirrors AccountPage.test.tsx)

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
ts
// 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' });
tsx
// 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 css import path and the Coins prop name against Coins.tsx / an existing feature's styles.ts before 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)

tsx
// 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
tsx
// 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} />;
}
tsx
// 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 /> }];
tsx
// 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
bash
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)

ts
// 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):
bash
pnpm --filter @gw2priory/api build && ANTHROPIC_API_KEY=sk-... node apps/api/dist/assistant/eval/intent.eval.js

Expected: 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
bash
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/useAskAssistant used 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.