v1.2.0:9 — fuzzy-match AI exercises to the library by name
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run

Both AI flows resolved suggested exercises to the user's library by exact exerciseId only, with no name fallback — so a model returning a good name with a null or invented id (e.g. "Overhead Press" when the library has "Overhead Press (barbell)") forced the user to hand-map an exercise they already own. Common with local models (Qwen via SparkControl) that don't reliably echo library ids.

Fix: a shared name matcher (lib/ai/exerciseMatch.ts) normalizes names (lowercase, strip the (barbell)-style qualifier + punctuation) and auto-resolves UNIQUE confident matches; ambiguous or unknown names stay flagged for manual mapping. Wired into both the workout and program generate flows at the parse->display boundary.

Client-only; no schema/data change. 274 tests pass; built + sideloaded to immense-voyage.local (1.2.0:9, clean non-root launch).
This commit is contained in:
Keysat
2026-06-19 16:17:57 -05:00
parent 794070a1d8
commit 891bf09d7e
7 changed files with 240 additions and 6 deletions
+6 -3
View File
@@ -116,13 +116,16 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state
Latest version is **1.2.0:8****decimal-tolerant AI parsing**, a fix-forward on the 1.2.0:7 SparkControl ship. **SparkControl is confirmed working end-to-end on-box** (smoke-tested with `RedHatAI/Qwen3.6-35B-A3B-NVFP4`: connected, streamed 9.7k in / 5.3k out, `$0`/FREE, model auto-detected). The smoke test surfaced one bug: local models emit decimals where the AI schema expected integers (a half-step `"rpe": 7.5`), and zod `.int()` failed the **entire** parse. Fix: a shared `looseInt` (`programSchema.ts`, used by `workoutSchema.ts`) rounds a number to the nearest int **before** the `.int()` check, applied to every integer field in both the program and single-workout schemas (rpe, reps, sets, gear, order, durationSeconds, rest/week/day numbers). Parse-only, types unchanged.
Latest version is **1.2.0:9**the **SparkControl + local-model arc is complete and confirmed working on-box** (smoke-tested with `RedHatAI/Qwen3.6-35B-A3B-NVFP4`: connected, model auto-detected, `$0`/FREE). Three ships:
- **:7** added **SparkControl (local)**, a 6th AI provider — the operator's own self-hosted local-inference gateway. OpenAI-compatible (reuses `generateOpenAIStyle`), **keyless** (`requireApiKey:false` → no `Authorization` header), reached over the **internal same-box address** `http://spark-control.startos:9999/v1` (plain HTTP, no TLS/cert-skip), model auto-detected via `/api/endpoints` (`app/api/ai/sparkcontrol/model`, admin-only + SSRF-guarded). Plus a **base-URL footgun fix**: a custom URL could attach to a fixed-URL provider (claude/openai/gemini) and be silently ignored — both config write paths now null `baseUrl` for non-custom-URL providers + the form clears it on provider change.
- **:8** made AI parsing **decimal-tolerant**`looseInt` (`programSchema.ts`, used by `workoutSchema.ts`) rounds a float (e.g. a half-step `rpe:7.5`) before the zod `.int()` check, on every integer field in both schemas. A local model had failed the whole parse on one decimal.
- **:9** added **fuzzy exercise→library matching** (`lib/ai/exerciseMatch.ts`): when the model's `exerciseId` misses, normalize the name (strip `(barbell)` etc.) and auto-map **unique confident** matches in both generate flows (`Overhead Press``Overhead Press (barbell)`); ambiguous names stay manual.
**1.2.0:7 (the SparkControl feature itself):** adds a 6th provider, **SparkControl (local)** — the operator's own self-hosted local-inference gateway. OpenAI-compatible wire format (reuses `generateOpenAIStyle`), **keyless** on the LAN (`requireApiKey:false` → no `Authorization` header), reached over the **internal same-box StartOS address** `http://spark-control.startos:9999/v1` (plain HTTP — no TLS/cert-skip; the public LAN interface is HTTPS w/ a self-signed cert we deliberately avoid). The Settings form **auto-detects the loaded vLLM model** via SparkControl's `/api/endpoints` (`app/api/ai/sparkcontrol/model`, admin-only + SSRF-guarded); $0 in the cost UI. Also fixed a **base-URL footgun**: a custom URL could ride along to a fixed-URL provider (claude/openai/gemini), get stored, and be silently ignored — both config write paths now null `baseUrl` for non-custom-URL providers and the form clears it on provider change. **No schema/data change** (`AIConfigProfile.provider` is free-text). Details: `docs/guides/ai-subsystem.md` → Provider abstraction (incl. the "adding a provider" fan-out checklist). **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M). tsc clean (app + packaging), lint clean (pre-existing only), **261 tests pass**, `next build` + s9pk build succeed. Registry empty, **publishing parked** (sideload-only via `make install`).
Whole arc is **client/parse-only — no schema/data change** (`AIConfigProfile.provider` is free-text). Mechanics in `docs/guides/ai-subsystem.md` (provider abstraction + "adding a provider" fan-out checklist). **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M); tsc clean (app + packaging), lint clean (pre-existing only), **274 tests pass**, `next build` + s9pk build succeed. Registry empty, **publishing parked** (sideload-only via `make install`).
**Design contract established this session (2026-06-19, committed `7fda9ce`, no UI code changed):** the `design/` folder now holds the durable contract — `DESIGN.md` (9-section brief) + `tokens.tokens.json` (DTCG) + `brand/palette.css` + `inspiration/` provenance. From a Case-B *document-as-is* extract of the as-built dark UI: **monochrome gym-brutalist** (`#0A0A0A` canvas, zinc-only neutral, white primary button, Bebas-uppercase/tracked headings, flat border-based depth), plus two owner calls — **red elevated to the single brand accent `#DC2626`** and a **two-tier radius** (4px controls / 8px containers). AGENTS.md carries the read-before-UI Design line; `ROADMAP.md`**Design** holds the `design-checker` cleanup backlog (gray→zinc, green→emerald, yellow→amber, `rounded-md``rounded`, overlay-only shadows, and a shared `<Button>`). **Read `design/DESIGN.md` before any UI work.**
**Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:8`; launched `as nextjs` with no errors, "Ready in 221ms", and (correctly) **no migration ran** (neither :7 nor :8 adds a column). The operator added a SparkControl config and generated through it successfully (modulo the decimal bug now fixed in :8). Recent prior ships (1.2.0 line): **1.2.0:6** AI "today's workout"; **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field.
**Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:9`; launched `as nextjs` with no errors, "Ready in 222ms", and (correctly) **no migration ran** (none of :7:9 add a column). Operator is generating workouts through SparkControl successfully. Recent prior ships (1.2.0 line): **1.2.0:6** AI "today's workout"; **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field.
**On-box follow-up:** the operator is now on the SparkControl config; the old misconfigured `gemini`+`baseUrl` profile (the empty-response trigger) is no longer active — delete it at leisure (cosmetic). Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced through 1.2.0:5 (the workout feature didn't touch auth), first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
+15 -1
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { Loader2, Sparkles } from 'lucide-react';
import { lenientJsonParse } from '@/lib/ai/lenientJson';
import { estimateCost, formatCost } from '@/lib/ai/pricing';
import { resolveExerciseIds } from '@/lib/ai/exerciseMatch';
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -176,10 +177,23 @@ export default function GenerateClient({
if (r.ok) {
const gen = await r.json();
if (gen.parsedProgram) {
const parsed = JSON.parse(gen.parsedProgram) as AIProgram;
// Auto-resolve exercises the model named but didn't (or wrongly)
// id'd against the library, so the user isn't asked to hand-map an
// exercise they already own. Ambiguous ones stay unmapped.
setPhase({
kind: 'parsed',
raw,
program: JSON.parse(gen.parsedProgram) as AIProgram,
program: {
...parsed,
weeks: parsed.weeks.map((w) => ({
...w,
days: w.days.map((d) => ({
...d,
exercises: resolveExerciseIds(d.exercises, exercises),
})),
})),
},
});
return;
}
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { Loader2, Sparkles } from 'lucide-react';
import { lenientJsonParse } from '@/lib/ai/lenientJson';
import { estimateCost, formatCost } from '@/lib/ai/pricing';
import { resolveExerciseIds } from '@/lib/ai/exerciseMatch';
import type { AiWorkoutDraft } from '@/lib/ai/workoutDraft';
interface LibraryExercise {
@@ -136,7 +137,15 @@ export default function GenerateWorkoutClient({
if (r.ok) {
const gen = await r.json();
if (gen.parsedProgram) {
setWorkout(JSON.parse(gen.parsedProgram) as AIWorkout);
const parsed = JSON.parse(gen.parsedProgram) as AIWorkout;
// Auto-resolve exercises the model named but didn't (or wrongly)
// id'd against the library, so the user isn't asked to hand-map an
// exercise they already own (e.g. "Overhead Press" -> "Overhead
// Press (barbell)"). Ambiguous ones stay unmapped.
setWorkout({
...parsed,
exercises: resolveExerciseIds(parsed.exercises, exercises),
});
setRefineInput(''); // consumed — clear only on success
setPhase({ kind: 'idle' });
return;
+85
View File
@@ -0,0 +1,85 @@
/**
* Resolve an AI-suggested exercise to a library exercise id by NAME — a
* fallback for when the model's `exerciseId` is missing or isn't in the
* library. Models (local ones especially) often return a good display name
* with a null or invented id, e.g. "Overhead Press" when the library has
* "Overhead Press (barbell)".
*
* Rather than make the user hand-map an exercise they clearly already own, we
* match on a normalized name and auto-resolve only when the match is
* UNAMBIGUOUS. Ambiguous or no-match cases return null so the UI still flags
* them for manual mapping — a wrong auto-map is worse than asking.
*/
export interface LibraryEntry {
id: string;
name: string;
}
/**
* Lowercase, drop parenthetical qualifiers ("(barbell)", "(dumbbell)"), and
* collapse punctuation/whitespace to single spaces — so "Overhead Press
* (Barbell)" and "overhead-press" both normalize to "overhead press".
*/
export function normalizeExerciseName(name: string): string {
return name
.toLowerCase()
.replace(/\([^)]*\)/g, ' ') // strip "(barbell)" etc.
.replace(/[^a-z0-9]+/g, ' ') // punctuation/symbols → space
.trim()
.replace(/\s+/g, ' ');
}
/**
* Best confident library id for a suggested name, or null when there's no
* match OR the match is ambiguous (multiple library exercises fit).
* Conservative by design.
*/
export function matchLibraryExerciseId(
name: string,
library: LibraryEntry[],
): string | null {
const q = normalizeExerciseName(name);
if (!q) return null;
const normed = library.map((e) => ({
id: e.id,
norm: normalizeExerciseName(e.name),
}));
// 1. Exact normalized match. Unique → take it; a tie (e.g. barbell + dumbbell
// variants both normalize the same) → ambiguous, leave for manual mapping.
const exact = normed.filter((e) => e.norm === q);
if (exact.length === 1) return exact[0].id;
if (exact.length > 1) return null;
// 2. One-sided prefix on a word boundary: the suggested name is a prefix of
// exactly one library name, or vice-versa (catches non-parenthetical
// qualifiers like "Overhead Press Barbell"). Uniqueness keeps a generic
// word like "press" from mapping to anything.
const prefix = normed.filter(
(e) => e.norm.startsWith(q + ' ') || q.startsWith(e.norm + ' '),
);
if (prefix.length === 1) return prefix[0].id;
return null;
}
/**
* Fill in unresolved `exerciseId`s on a list of AI-suggested exercises by
* confident name match. Already-valid ids and unmatched names are left
* untouched (the latter stay null so the UI flags them for manual mapping).
*/
export function resolveExerciseIds<
T extends { exerciseId: string | null; exerciseName: string },
>(items: T[], library: LibraryEntry[]): T[] {
const libIds = new Set(library.map((e) => e.id));
return items.map((it) =>
it.exerciseId && libIds.has(it.exerciseId)
? it
: {
...it,
exerciseId: matchLibraryExerciseId(it.exerciseName, library) ?? it.exerciseId,
},
);
}
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import {
normalizeExerciseName,
matchLibraryExerciseId,
resolveExerciseIds,
} from '@/lib/ai/exerciseMatch';
const library = [
{ id: 'ohp', name: 'Overhead Press (barbell)' },
{ id: 'bench', name: 'Bench Press (barbell)' },
{ id: 'curl', name: 'Barbell Curl (barbell)' },
{ id: 'bike', name: 'Assault Bike (assault bike)' },
];
describe('normalizeExerciseName', () => {
it('lowercases, strips parens, and collapses punctuation', () => {
expect(normalizeExerciseName('Overhead Press (Barbell)')).toBe('overhead press');
expect(normalizeExerciseName('overhead-press')).toBe('overhead press');
expect(normalizeExerciseName(' Bench Press ')).toBe('bench press');
});
});
describe('matchLibraryExerciseId', () => {
it('maps a bare name to the parenthetical library variant (the reported case)', () => {
expect(matchLibraryExerciseId('Overhead Press', library)).toBe('ohp');
});
it('is case-insensitive', () => {
expect(matchLibraryExerciseId('overhead press', library)).toBe('ohp');
});
it('matches a non-parenthetical qualifier via unique prefix', () => {
const lib = [{ id: 'ohp', name: 'Overhead Press Barbell' }];
expect(matchLibraryExerciseId('Overhead Press', lib)).toBe('ohp');
});
it('returns null when the match is ambiguous (multiple variants normalize the same)', () => {
const ambiguous = [
{ id: 'ohp-bb', name: 'Overhead Press (barbell)' },
{ id: 'ohp-db', name: 'Overhead Press (dumbbell)' },
];
expect(matchLibraryExerciseId('Overhead Press', ambiguous)).toBeNull();
});
it('does not map a generic word that prefixes many exercises', () => {
// "press" is a prefix of both Overhead Press and Bench Press → not unique.
expect(matchLibraryExerciseId('Press', library)).toBeNull();
});
it('returns null for an exercise not in the library', () => {
expect(matchLibraryExerciseId('Zercher Squat', library)).toBeNull();
});
it('returns null for an empty/punctuation-only name', () => {
expect(matchLibraryExerciseId(' ', library)).toBeNull();
});
});
describe('resolveExerciseIds', () => {
it('fills a null id by confident name match', () => {
const items = [{ exerciseId: null, exerciseName: 'Overhead Press' }];
expect(resolveExerciseIds(items, library)[0].exerciseId).toBe('ohp');
});
it('repairs an invalid (non-library) id by name', () => {
const items = [{ exerciseId: 'made-up-id', exerciseName: 'Bench Press' }];
expect(resolveExerciseIds(items, library)[0].exerciseId).toBe('bench');
});
it('leaves a valid id untouched', () => {
const items = [{ exerciseId: 'curl', exerciseName: 'whatever' }];
expect(resolveExerciseIds(items, library)[0].exerciseId).toBe('curl');
});
it('leaves an unmatched name as null for manual mapping', () => {
const items = [{ exerciseId: null, exerciseName: 'Zercher Squat' }];
expect(resolveExerciseIds(items, library)[0].exerciseId).toBeNull();
});
it('preserves other fields', () => {
const items = [
{ exerciseId: null, exerciseName: 'Overhead Press', sets: 4, reps: 6 },
];
const out = resolveExerciseIds(items, library)[0];
expect(out).toMatchObject({ exerciseId: 'ohp', sets: 4, reps: 6 });
});
});
+6 -1
View File
@@ -23,6 +23,7 @@ import { v_1_2_0_5 } from './v1.2.0.5'
import { v_1_2_0_6 } from './v1.2.0.6'
import { v_1_2_0_7 } from './v1.2.0.7'
import { v_1_2_0_8 } from './v1.2.0.8'
import { v_1_2_0_9 } from './v1.2.0.9'
/**
* Version graph for the `proof-of-work` package.
@@ -96,9 +97,12 @@ import { v_1_2_0_8 } from './v1.2.0.8'
* float values (e.g. a half-step RPE 7.5 from a local model) before
* the .int() check, so one stray decimal no longer fails the whole
* generation. Parse-only; no schema/data change.
* v1.2.0:9 — Fuzzy-match AI exercises to the library by name (both generate
* flows): "Overhead Press" auto-maps to "Overhead Press (barbell)";
* ambiguous names stay manual. Client-only; no schema/data change.
*/
export const versionGraph = VersionGraph.of({
current: v_1_2_0_8,
current: v_1_2_0_9,
other: [
v_1_0_0_1,
v_1_0_0_2,
@@ -123,5 +127,6 @@ export const versionGraph = VersionGraph.of({
v_1_2_0_5,
v_1_2_0_6,
v_1_2_0_7,
v_1_2_0_8,
],
})
+31
View File
@@ -0,0 +1,31 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:9 — Fuzzy-match AI exercises to the library by name (2026-06-19).
*
* Both AI flows (program + single-workout) resolved suggested exercises to the
* user's library by EXACT `exerciseId` only, with no name fallback — so a model
* that returned a good name with a null or invented id (e.g. "Overhead Press"
* when the library has "Overhead Press (barbell)") forced the user to hand-map
* an exercise they already owned. Common with local models (Qwen via
* SparkControl) that don't reliably echo library ids.
*
* Fix: a shared name matcher (`lib/ai/exerciseMatch.ts`) normalizes names
* (lowercase, strip the "(barbell)"-style qualifier + punctuation) and
* auto-resolves UNIQUE confident matches; ambiguous or unknown names stay
* flagged for manual mapping (a wrong auto-map is worse than asking). Wired
* into both generate flows at the parse→display boundary.
*
* Client-only — no schema or data change.
*/
export const v_1_2_0_9 = VersionInfo.of({
version: '1.2.0:9',
releaseNotes: {
en_US:
'AI-suggested exercises now fuzzy-match your library by name — e.g. "Overhead Press" maps to your "Overhead Press (barbell)" automatically instead of asking you to map it by hand. Ambiguous matches are still left for you to pick. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})