Skip to content

Therapy Pack — Guided Seeding (Phase 3b) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Turn a resolved TargetSpec into a seeded pack — target words, contrast pairs, and sentences drawn from the existing words/contrastive/sentences APIs, difficulty-ordered, each item carrying sourceTool: 'guided' provenance — and materialize it as a new active pack in one store commit.

Architecture: Three layers, all client-side. (1) Pure mapping helpers (TargetSpec → API request params; API responses → PackItems) — fully unit-tested. (2) An async seedPack(target, deps) orchestrator that calls api.searchWords, api.getMinimalPairs, and fetchSentences with injectable deps, degrading gracefully (a failed section yields [], never a thrown seed). (3) A usePackStore.createSeededPack(target, seeded) action that builds the whole pack and commits once. Phase 3c wires this behind the front-page target box.

Tech Stack: TypeScript, React 19, Zustand 5, Vitest 4. Reuses api (src/services/apiClient.ts), fetchSentences (src/lib/generationApi.ts), the Phase 3a resolver output, and the Phase 1 pack store.

Global Constraints

  • Client-side only, existing endpoints. No new backend/route/table. Words via api.searchWords; contrasts via api.getMinimalPairs; sentences via fetchSentences. (spec §5.3)
  • Store is the single mutation surface. Seeding builds PackItems and hands them to a store action; it never touches packDb. (spec §5.6)
  • Provenance is mandatory. Every seeded item carries provenance: { sourceTool: 'guided', addedAt: <ms>, reason: <why> } (required for the future tight loop + target-inference). (spec §5.6)
  • Graceful partial seed. Each API section is independently try/caught — a failure yields [] for that section; seedPack never rejects. (spec §14 seeding-quality risk)
  • Difficulty ordering. Target words are ordered easiest-first via sort_by: 'frequency', sort_order: 'desc' (common words first — a valid SORTABLE_COLUMNS member). (spec §5.3)
  • Position→pattern mapping: initialSTARTS_WITH, medialCONTAINS_MEDIAL, finalENDS_WITH, anyCONTAINS.
  • Types (src/types/therapyPack.ts): TargetSpec, PackWord, PackContrast, PackSentence, PackItem, TherapyPack, createEmptyPack. PackSourceTool includes 'guided'. Do NOT modify these.
  • Test commands (from packages/web/frontend/): npm test -- --run <file>, npm run type-check, npm run lint.

File Structure

  • src/lib/seed/packSeeder.ts (create) — pure mapping helpers (patternTypeFor, mapWordToPackWord, mapPairToPackContrast, mapSentenceToPackSentence, sentenceConstraintsFor, wordSearchFor) and the async seedPack(target, deps?).
  • src/lib/seed/packSeeder.test.ts (create) — unit tests for the mappers + seedPack with mocked deps.
  • src/store/packStore.ts (modify) — add createSeededPack(target, seeded) action.
  • src/store/packStore.test.ts (modify) — test the new action.

Task 1: Pure seeding mappers + query builders

Files: - Create: src/lib/seed/packSeeder.ts (this task adds only the pure helpers; seedPack comes in Task 2) - Test: src/lib/seed/packSeeder.test.ts

Interfaces: - Consumes: TargetSpec, PackWord, PackContrast, PackSentence from ../../types/therapyPack; newId from ../id; Pattern, PatternType, WordSearchRequest, Word from ../../services/apiClient; MinimalPairResult from ../../types/phonology; Constraint, CorpusMatch from ../../types/governance. - Produces: - export function patternTypeFor(position: TargetSpec['position']): PatternType — the position→PatternType map (default CONTAINS). - export function wordSearchFor(target: TargetSpec, limit?: number): WordSearchRequest — a search body for the primary phone (target.phones[0]) at target.position (defaulting initial for contrast/process, else the target's position or any), sort_by: 'frequency', sort_order: 'desc', limit default 20. - export function sentenceConstraintsFor(target: TargetSpec): Constraint[] — for contrast mode a single contrastive_minpair constraint (phoneme1/phoneme2, position); otherwise a single pattern include-constraint for the primary phone. - export function mapWordToPackWord(w: Word, at: number): PackWord - export function mapPairToPackContrast(p: MinimalPairResult, at: number): PackContrast - export function mapSentenceToPackSentence(m: CorpusMatch, at: number): PackSentencetargets = the union of highlights.include_surfaces and highlights.pair_surfaces.

  • [ ] Step 1: Write the failing test

Create src/lib/seed/packSeeder.test.ts:

import { describe, it, expect } from 'vitest';
import {
  patternTypeFor, wordSearchFor, sentenceConstraintsFor,
  mapWordToPackWord, mapPairToPackContrast, mapSentenceToPackSentence,
} from './packSeeder';
import type { TargetSpec } from '../../types/therapyPack';

describe('packSeeder pure helpers', () => {
  it('patternTypeFor maps positions to pattern types', () => {
    expect(patternTypeFor('initial')).toBe('STARTS_WITH');
    expect(patternTypeFor('medial')).toBe('CONTAINS_MEDIAL');
    expect(patternTypeFor('final')).toBe('ENDS_WITH');
    expect(patternTypeFor('any')).toBe('CONTAINS');
    expect(patternTypeFor(undefined)).toBe('CONTAINS');
  });

  it('wordSearchFor builds a frequency-sorted search for the primary phone', () => {
    const t: TargetSpec = { mode: 'sound', phones: ['ɹ'], position: 'initial' };
    const body = wordSearchFor(t);
    expect(body.patterns).toEqual([{ type: 'STARTS_WITH', phoneme: 'ɹ' }]);
    expect(body.sort_by).toBe('frequency');
    expect(body.sort_order).toBe('desc');
    expect(body.limit).toBe(20);
  });

  it('wordSearchFor defaults contrast/process position to initial', () => {
    const body = wordSearchFor({ mode: 'contrast', phones: ['k', 't'] });
    expect(body.patterns).toEqual([{ type: 'STARTS_WITH', phoneme: 'k' }]);
  });

  it('sentenceConstraintsFor emits a minpair constraint for a contrast', () => {
    const c = sentenceConstraintsFor({ mode: 'contrast', phones: ['k', 't'], position: 'initial' });
    expect(c).toEqual([{ type: 'contrastive_minpair', phoneme1: 'k', phoneme2: 't', position: 'initial' }]);
  });

  it('sentenceConstraintsFor emits a pattern constraint for a sound', () => {
    const c = sentenceConstraintsFor({ mode: 'sound', phones: ['s'], position: 'final' });
    expect(c).toEqual([{ type: 'pattern', pattern_type: 'ENDS_WITH', phonemes: ['s'], mode: 'include' }]);
  });

  it('mapWordToPackWord carries word/ipa/image + guided provenance', () => {
    const w = { word: 'rocket', ipa: 'ɹ ɑ k ə t', has_image: 1, image_file: 'rocket.svg', image_provider: 'mulberry' } as never;
    const pw = mapWordToPackWord(w, 123);
    expect(pw).toMatchObject({ kind: 'word', word: 'rocket', ipa: 'ɹ ɑ k ə t', hasImage: true });
    expect(pw.provenance).toMatchObject({ sourceTool: 'guided', addedAt: 123 });
    expect(pw.id).toBeTruthy();
  });

  it('mapPairToPackContrast builds a labeled contrast', () => {
    const p = { word1: { word: 'key' }, word2: { word: 'tea' }, phoneme1: 'k', phoneme2: 't' } as never;
    const pc = mapPairToPackContrast(p, 5);
    expect(pc).toMatchObject({ kind: 'contrast', a: 'key', b: 'tea', label: 'k vs t' });
    expect(pc.provenance.sourceTool).toBe('guided');
  });

  it('mapSentenceToPackSentence unions the highlight surfaces as targets', () => {
    const m = { text: 'The key is here.', highlights: { include_surfaces: ['key'], pair_surfaces: ['tea'] } } as never;
    const ps = mapSentenceToPackSentence(m, 9);
    expect(ps).toMatchObject({ kind: 'sentence', text: 'The key is here.' });
    expect(ps.targets.sort()).toEqual(['key', 'tea']);
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/lib/seed/packSeeder.test.ts Expected: FAIL — module not found.

  • [ ] Step 3: Implement the pure helpers

Create src/lib/seed/packSeeder.ts:

/**
 * Guided seeding (spec §5.3): a resolved TargetSpec → target words, contrast
 * pairs, and sentences via the existing words/contrastive/sentences APIs.
 * This file holds the pure mappers; seedPack (Task 2) orchestrates the calls.
 */
import type {
  TargetSpec, PackWord, PackContrast, PackSentence,
} from '../../types/therapyPack';
import { newId } from '../id';
import type { Pattern, PatternType, WordSearchRequest, Word } from '../../services/apiClient';
import type { MinimalPairResult } from '../../types/phonology';
import type { Constraint, CorpusMatch } from '../../types/governance';

export function patternTypeFor(position: TargetSpec['position']): PatternType {
  switch (position) {
    case 'initial': return 'STARTS_WITH';
    case 'medial': return 'CONTAINS_MEDIAL';
    case 'final': return 'ENDS_WITH';
    default: return 'CONTAINS';
  }
}

/** Position to search at: the target's own position for a single sound;
 *  'initial' as the sensible default for contrast/process targets. */
function seedPosition(target: TargetSpec): TargetSpec['position'] {
  if (target.mode === 'sound') return target.position ?? 'any';
  return target.position ?? 'initial';
}

export function wordSearchFor(target: TargetSpec, limit = 20): WordSearchRequest {
  const phone = target.phones[0];
  const pattern: Pattern = { type: patternTypeFor(seedPosition(target)), phoneme: phone };
  return { patterns: [pattern], sort_by: 'frequency', sort_order: 'desc', limit };
}

export function sentenceConstraintsFor(target: TargetSpec): Constraint[] {
  const pos = seedPosition(target);
  if (target.mode === 'contrast' && target.phones.length >= 2) {
    return [{
      type: 'contrastive_minpair',
      phoneme1: target.phones[0],
      phoneme2: target.phones[1],
      position: pos === 'any' ? 'any' : pos,
    }];
  }
  return [{
    type: 'pattern',
    pattern_type: patternTypeFor(pos),
    phonemes: [target.phones[0]],
    mode: 'include',
  }];
}

export function mapWordToPackWord(w: Word, at: number): PackWord {
  return {
    kind: 'word',
    id: newId(),
    word: w.word,
    ipa: w.ipa ?? undefined,
    hasImage: w.has_image ? true : undefined,
    provenance: { sourceTool: 'guided', addedAt: at, reason: 'seeded target word' },
  };
}

export function mapPairToPackContrast(p: MinimalPairResult, at: number): PackContrast {
  return {
    kind: 'contrast',
    id: newId(),
    a: p.word1.word,
    b: p.word2.word,
    label: `${p.phoneme1} vs ${p.phoneme2}`,
    provenance: { sourceTool: 'guided', addedAt: at, reason: 'seeded minimal pair' },
  };
}

export function mapSentenceToPackSentence(m: CorpusMatch, at: number): PackSentence {
  const targets = Array.from(new Set([
    ...(m.highlights?.include_surfaces ?? []),
    ...(m.highlights?.pair_surfaces ?? []),
  ]));
  return {
    kind: 'sentence',
    id: newId(),
    text: m.text,
    targets,
    provenance: { sourceTool: 'guided', addedAt: at, reason: 'seeded sentence' },
  };
}
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/lib/seed/packSeeder.test.ts Expected: PASS (8/8).

  • [ ] Step 5: Type-check and commit
npm run type-check
git add src/lib/seed/packSeeder.ts src/lib/seed/packSeeder.test.ts
git commit -m "feat(packs): pure guided-seeding mappers + query builders (PHON-170)"

Task 2: seedPack orchestrator (graceful, injectable deps)

Files: - Modify: src/lib/seed/packSeeder.ts - Test: src/lib/seed/packSeeder.test.ts

Interfaces: - Consumes: the Task-1 helpers; api from ../../services/apiClient (searchWords, getMinimalPairs); fetchSentences from ../generationApi. - Produces: - export interface SeededContent { words: PackWord[]; contrasts: PackContrast[]; sentences: PackSentence[]; } - export interface SeedDeps { searchWords: typeof api.searchWords; getMinimalPairs: typeof api.getMinimalPairs; fetchSentences: typeof fetchSentences; now: () => number; } - export async function seedPack(target: TargetSpec, deps?: Partial<SeedDeps>): Promise<SeededContent> — resolves words (all modes), contrasts (contrast mode only), and sentences (all modes) concurrently; each section is independently guarded so one failure yields [] for that section and never rejects the whole. Word/contrast/sentence counts capped (words 20, contrasts 12, sentences 10).

  • [ ] Step 1: Write the failing test

Append to src/lib/seed/packSeeder.test.ts:

import { seedPack } from './packSeeder';
import { vi } from 'vitest';

describe('seedPack orchestrator', () => {
  const baseDeps = () => ({
    searchWords: vi.fn().mockResolvedValue({ items: [{ word: 'rocket', ipa: 'ɹ', has_image: 1 }], total: 1, offset: 0, limit: 20 }),
    getMinimalPairs: vi.fn().mockResolvedValue([{ word1: { word: 'key' }, word2: { word: 'tea' }, phoneme1: 'k', phoneme2: 't' }]),
    fetchSentences: vi.fn().mockResolvedValue({ corpus_matches: [{ text: 'A rocket.', highlights: { include_surfaces: ['rocket'], pair_surfaces: [] } }], total: 1, elapsed_ms: { corpus: 1, total: 1 } }),
    now: () => 100,
  });

  it('seeds words + sentences for a sound target (no contrasts)', async () => {
    const deps = baseDeps();
    const out = await seedPack({ mode: 'sound', phones: ['ɹ'], position: 'initial' }, deps);
    expect(out.words).toHaveLength(1);
    expect(out.words[0].word).toBe('rocket');
    expect(out.sentences).toHaveLength(1);
    expect(out.contrasts).toEqual([]);
    expect(deps.getMinimalPairs).not.toHaveBeenCalled();
  });

  it('seeds contrasts for a contrast target', async () => {
    const deps = baseDeps();
    const out = await seedPack({ mode: 'contrast', phones: ['k', 't'], position: 'initial' }, deps);
    expect(out.contrasts).toHaveLength(1);
    expect(out.contrasts[0]).toMatchObject({ a: 'key', b: 'tea' });
    expect(deps.getMinimalPairs).toHaveBeenCalled();
  });

  it('degrades gracefully when a section fails', async () => {
    const deps = baseDeps();
    deps.fetchSentences = vi.fn().mockRejectedValue(new Error('sentences down'));
    const out = await seedPack({ mode: 'sound', phones: ['s'], position: 'final' }, deps);
    expect(out.words).toHaveLength(1);   // words still seeded
    expect(out.sentences).toEqual([]);   // failed section is empty, no throw
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/lib/seed/packSeeder.test.ts Expected: FAIL — seedPack is not a function.

  • [ ] Step 3: Implement seedPack

Append to src/lib/seed/packSeeder.ts (add imports for api and fetchSentences at the top):

import { api } from '../../services/apiClient';
import { fetchSentences } from '../generationApi';

export interface SeededContent {
  words: PackWord[];
  contrasts: PackContrast[];
  sentences: PackSentence[];
}

export interface SeedDeps {
  searchWords: typeof api.searchWords;
  getMinimalPairs: typeof api.getMinimalPairs;
  fetchSentences: typeof fetchSentences;
  now: () => number;
}

async function safe<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
  try { return await fn(); } catch { return fallback; }
}

export async function seedPack(
  target: TargetSpec,
  deps: Partial<SeedDeps> = {},
): Promise<SeededContent> {
  const searchWords = deps.searchWords ?? api.searchWords.bind(api);
  const getMinimalPairs = deps.getMinimalPairs ?? api.getMinimalPairs.bind(api);
  const fetchSents = deps.fetchSentences ?? fetchSentences;
  const now = deps.now ?? Date.now;
  const at = now();
  const pos = seedPosition(target);

  const wordsP = safe(async () => {
    const res = await searchWords(wordSearchFor(target, 20));
    return res.items.slice(0, 20).map((w) => mapWordToPackWord(w, at));
  }, [] as PackWord[]);

  const contrastsP = target.mode === 'contrast' && target.phones.length >= 2
    ? safe(async () => {
        const pairs = await getMinimalPairs({
          phoneme1: target.phones[0],
          phoneme2: target.phones[1],
          position: pos === 'any' ? undefined : pos,
          limit: 12,
        });
        return pairs.slice(0, 12).map((p) => mapPairToPackContrast(p, at));
      }, [] as PackContrast[])
    : Promise.resolve([] as PackContrast[]);

  const sentencesP = safe(async () => {
    const res = await fetchSents({ constraints: sentenceConstraintsFor(target), top_k: 10 });
    return res.corpus_matches.slice(0, 10).map((m) => mapSentenceToPackSentence(m, at));
  }, [] as PackSentence[]);

  const [words, contrasts, sentences] = await Promise.all([wordsP, contrastsP, sentencesP]);
  return { words, contrasts, sentences };
}
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/lib/seed/packSeeder.test.ts Expected: PASS (11/11 — 8 from Task 1 + 3 here).

  • [ ] Step 5: Type-check, lint, commit
npm run type-check
npm run lint
git add src/lib/seed/packSeeder.ts src/lib/seed/packSeeder.test.ts
git commit -m "feat(packs): seedPack orchestrator over existing APIs (graceful partial seed) (PHON-170)"

Task 3: createSeededPack store action

Files: - Modify: src/store/packStore.ts - Test: src/store/packStore.test.ts

Interfaces: - Consumes: the store's existing commit helper, createEmptyPack, newId, TargetSpec, SeededContent (from ../lib/seed/packSeeder). - Produces (added to PackState): - createSeededPack: (target: TargetSpec, seeded: SeededContent, title?: string) => void — creates a brand-new pack (fresh id, createdAt/updatedAt now), sets target, fills words/contrasts/sentences from seeded, sets title (default: the target's describe label if provided, else 'Untitled pack'), and commits once (single persist, single library update). Becomes the active pack.

  • [ ] Step 1: Write the failing test

Append to src/store/packStore.test.ts (reuse the existing describe('usePackStore') block or a sibling):

  it('createSeededPack builds and commits a filled pack in one go', async () => {
    const seeded = {
      words: [{ kind: 'word', id: 'w1', word: 'rocket', provenance: { sourceTool: 'guided', addedAt: 1 } }],
      contrasts: [{ kind: 'contrast', id: 'c1', a: 'key', b: 'tea', label: 'k vs t', provenance: { sourceTool: 'guided', addedAt: 1 } }],
      sentences: [{ kind: 'sentence', id: 's1', text: 'A rocket.', targets: ['rocket'], provenance: { sourceTool: 'guided', addedAt: 1 } }],
    } as never;
    store.getState().createSeededPack({ mode: 'sound', phones: ['ɹ'], position: 'initial' }, seeded, 'Initial /ɹ/');
    const p = store.getState().activePack!;
    expect(p.title).toBe('Initial /ɹ/');
    expect(p.target).toMatchObject({ mode: 'sound', phones: ['ɹ'] });
    expect(p.words).toHaveLength(1);
    expect(p.contrasts).toHaveLength(1);
    expect(p.sentences).toHaveLength(1);
    // persisted
    expect((await getAllPacks()).find((x) => x.id === p.id)!.words).toHaveLength(1);
  });
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- --run src/store/packStore.test.ts Expected: FAIL — createSeededPack is not a function.

  • [ ] Step 3: Implement the action

In src/store/packStore.ts: import the seeded type, add to the interface, implement.

Add import:

import type { SeededContent } from '../lib/seed/packSeeder';
Add to PackState:
  createSeededPack: (target: TargetSpec, seeded: SeededContent, title?: string) => void;
Implement (near newPack):
    createSeededPack(target, seeded, title) {
      const pack = createEmptyPack(newId(), Date.now());
      pack.target = target;
      pack.title = title ?? 'Untitled pack';
      pack.words = seeded.words;
      pack.contrasts = seeded.contrasts;
      pack.sentences = seeded.sentences;
      commit(pack);
    },

  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- --run src/store/packStore.test.ts Expected: PASS (all existing + the new one).

  • [ ] Step 5: Full gate + commit
npm test -- --run
npm run type-check
npm run lint
git add src/store/packStore.ts src/store/packStore.test.ts
git commit -m "feat(packs): createSeededPack store action (single-commit seeded pack) (PHON-170)"

Self-Review notes (coverage vs spec §5.3)

  • Seed via existing endpoints (words/contrastive/sentences): Task 2 seedPack over api.searchWords/api.getMinimalPairs/fetchSentences. ✓
  • Difficulty-ordered target words: wordSearchFor uses sort_by: 'frequency', sort_order: 'desc' (common-first). ✓
  • 15–25 candidates: words capped at 20. ✓
  • Contrast pairs (minimal): contrast mode → getMinimalPairs. (Maximal/multiple opposition deferred — minimal covers the MVP contrast.) ✓ (partial)
  • Sentences: fetchSentences with a pattern/minpair constraint. ✓
  • Assembled into a pack draft opened in the Editor: createSeededPack builds the active pack; Phase 3c opens the Editor on it. ✓
  • Provenance guided: every mapper stamps it. ✓
  • Graceful seeding quality risk (spec §14): partial-failure tolerance in seedPack. ✓
  • Deferred to Phase 3c/later: the front-page target box that calls resolveTargetseedPackcreateSeededPack → open Editor (3c); a loading/skeleton state during seeding (3c); richer per-phone word seeding for process/contrast targets (primary-phone only in 3b — noted); maximal/multiple opposition seeding; the guided "refine position/level" step (3c may fold the resolver's position in directly).

Decisions resolved at planning

  1. Primary-phone word seeding. For multi-phone targets (contrast/process), words are seeded from phones[0] only (one search). Per-phone fan-out is a later enrichment; the contrast pairs already surface both phones' words.
  2. Minimal opposition only for contrast seeding in 3b (maximal/multiple opposition deferred).
  3. sort_by: 'frequency' as the difficulty proxy (broad coverage; AoA is canonical-only and sparser). Common-first = easier-first.
  4. Single-commit seeding via createSeededPack (not newPack + N× addItem) to avoid N re-renders/persists.