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 viaapi.getMinimalPairs; sentences viafetchSentences. (spec §5.3) - Store is the single mutation surface. Seeding builds
PackItems and hands them to a store action; it never touchespackDb. (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;seedPacknever 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 validSORTABLE_COLUMNSmember). (spec §5.3) - Position→pattern mapping:
initial→STARTS_WITH,medial→CONTAINS_MEDIAL,final→ENDS_WITH,any→CONTAINS. - Types (
src/types/therapyPack.ts):TargetSpec,PackWord,PackContrast,PackSentence,PackItem,TherapyPack,createEmptyPack.PackSourceToolincludes'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 asyncseedPack(target, deps?).src/lib/seed/packSeeder.test.ts(create) — unit tests for the mappers +seedPackwith mocked deps.src/store/packStore.ts(modify) — addcreateSeededPack(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): PackSentence — targets = 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';
PackState:
createSeededPack: (target: TargetSpec, seeded: SeededContent, title?: string) => void;
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
seedPackoverapi.searchWords/api.getMinimalPairs/fetchSentences. ✓ - Difficulty-ordered target words:
wordSearchForusessort_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:
fetchSentenceswith a pattern/minpair constraint. ✓ - Assembled into a pack draft opened in the Editor:
createSeededPackbuilds 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
resolveTarget→seedPack→createSeededPack→ 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¶
- 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. - Minimal opposition only for contrast seeding in 3b (maximal/multiple opposition deferred).
sort_by: 'frequency'as the difficulty proxy (broad coverage; AoA is canonical-only and sparser). Common-first = easier-first.- Single-commit seeding via
createSeededPack(notnewPack+ N×addItem) to avoid N re-renders/persists.