feat: Client-side profile recommendation engine for Capacitor/direct mode#401
feat: Client-side profile recommendation engine for Capacitor/direct mode#401hessius merged 12 commits intoversion/2.4.0from
Conversation
…mode
Port the Python recommendation engine to TypeScript so 'Find Similar
Profiles' and tag-based recommendations work in the native iOS app
(Capacitor/direct mode) without requiring the Python backend.
New files:
- profileAnalysis.ts: Unified profile structural analysis (fingerprint
extraction, name tag extraction). Faithful port of Python
_extract_fingerprint() and _extract_name_tags().
- profileRecommendation.ts: Client-side scoring engine with same
algorithm as Python (structure 35%, tags 25%, weight 15%, pressure
15%, temperature 10%). Pure functions, no caching.
- 65 parity tests matching Python test_recommendations.py fixtures.
DirectModeInterceptor now calls the client-side engine instead of
returning empty { recommendations: [] } for /api/profiles/find-similar
and /api/profiles/recommend endpoints.
Closes #398
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove useless initial assignment to controlMode (no-useless-assignment) - Remove unused makeStage import from test file - Remove unused sourceFp/sourceTags variables from scoring parity test - Add missing else branch for controlMode = 'unknown' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix "Unknown-controlled" tag: skip confusing label when controlMode is unknown - Deduplicate "Technique: flat" vs "Flat profile" match reasons - Group temperature into ranges (Low/Medium/High temp) instead of exact °C - Extend tag color system: new technique + temperature categories with CSS - Add getMatchReasonColorClass() and getScoreColorClass() for colored badges - Color-coded match percentage: green ≥75%, amber 50-74%, orange 25-49%, gray <25% - Color-coded match reason tags using tag color system (light/dark support) - Fix dialog close button: 44px touch target, remove outline state, add data-sound - Fix FindSimilarOverlay: overflow hidden on cards, larger profile images - Align ProfileRecommendations with FindSimilarOverlay visual style (images, colors) - Sound effects: back/close navigation uses same click sound as forward navigation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…perature ranges - Fix profile images in direct/Capacitor mode using resolveDisplayImage() instead of image-proxy URLs (which don't go through fetch interceptor) - Remove duplicate explanation subtitle from both FindSimilarOverlay and ProfileRecommendations (was repeating tag content) - Remove DialogDescription from FindSimilarOverlay modal - Fix close button appearing selected on open (focus → focus-visible) - Add min-w-0 to grid children to fix card overflow past right padding - Update temperature ranges to user's preferred intervals: <82°C, 82-84, 85-87, 88-90, 91-93, 94-99°C - Sync ProfileRecommendations to match FindSimilarOverlay visual style Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix handleViewProfileByName to work in direct/Capacitor mode by fetching profile data via interceptor instead of bailing out - Wire onUseProfile prop to ProfileRecommendations in FormView - Fix close button vertical alignment: use padding+negative margin for 44px touch target while keeping icon aligned with title Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two root causes fixed:
1. Missing key prop on ProfileDetailView — React reused the same
component instance when navigating between profiles within the
same viewState, so local state never reset. Added key={entry.id}.
2. handleViewProfileByName silently bailed when profile cache was
empty — the find-similar interceptor fetches profiles directly
but doesn't populate _profileCache. Removed hard bail, now
proceeds to fetch profile JSON even without cache hit.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When navigating from one profile to another via Find Similar, the scroll position was carried over from the previous page. Now ProfileDetailView scrolls both window and the overflow-y-auto content container to top on mount. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Ports the backend profile recommendation logic to the client so recommendations and “Find Similar Profiles” work in Capacitor/direct mode (no Python backend), and updates UI to display richer recommendation metadata.
Changes:
- Added TypeScript profile analysis + scoring engine with parity tests.
- Updated
DirectModeInterceptorto serve/api/profiles/find-similarand/api/profiles/recommendusing the client-side engine in direct/native mode. - Enhanced recommendations UI (match reason badges, score badge colors, profile images) and added new tag categories/styles.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/web/src/views/FormView.tsx | Adds optional callback to open a recommended profile from the form view. |
| apps/web/src/services/interceptor/DirectModeInterceptor.ts | Replaces empty recommendation stubs with client-side scoring backed by machine profile list fetch. |
| apps/web/src/lib/tags.ts | Adds technique/temperature tag categories + helpers for match-reason and score badge styling. |
| apps/web/src/lib/profileAnalysis.ts | New unified structural fingerprint + keyword extraction module (TS port). |
| apps/web/src/lib/profileAnalysis.test.ts | New parity tests for fingerprint and name-tag extraction. |
| apps/web/src/lib/profileRecommendation.ts | New client-side scoring + recommendation APIs (TS port). |
| apps/web/src/lib/profileRecommendation.test.ts | New parity/ranking tests for recommendations and similarity. |
| apps/web/src/index.css | Adds CSS classes for new tag categories and score badges. |
| apps/web/src/hooks/useSoundEffects.ts | Adjusts global delegation sound mapping for back/close interactions. |
| apps/web/src/components/ui/dialog.tsx | Updates close button styling and adds data-sound="close". |
| apps/web/src/components/ProfileRecommendations.tsx | Revamps recommendation cards (images, match badges, clickable selection). |
| apps/web/src/components/FindSimilarOverlay.tsx | Revamps “find similar” overlay cards (images, match badges, clickable selection). |
| apps/web/src/App.tsx | Enables viewing a profile by name in direct/native mode by fetching profile JSON and building a synthetic HistoryEntry. |
| // Try cache first, then fall back to fetching the full profile list | ||
| const cacheRes = await fetch(`/api/profile/${encodeURIComponent(profileName)}`) | ||
| if (cacheRes.ok) { | ||
| const cacheData = await cacheRes.json() | ||
| if (cacheData?.profile?.id) { | ||
| profileId = cacheData.profile.id | ||
| displayImage = cacheData.profile.display?.image | ||
| } | ||
| } | ||
|
|
||
| // Fetch profile JSON for the breakdown view | ||
| const jsonRes = await fetch(`/api/machine/profile/${encodeURIComponent(profileId)}/json`) | ||
| const jsonData = jsonRes.ok ? await jsonRes.json() : {} | ||
| const profileJson = jsonData?.profile ?? null |
There was a problem hiding this comment.
The comment says "Try cache first, then fall back to fetching the full profile list", but the code never falls back if /api/profile/:name doesn't return an id. In that case profileId remains the name and /api/machine/profile/${profileId}/json will likely fail (producing a HistoryEntry with null profile_json). Add the intended fallback (e.g. fetch /api/machine/profiles or /api/v1/profile/list and resolve id by name), or early-return when the id can't be resolved.
There was a problem hiding this comment.
Already addressed. The fallback to /api/v1/profile/list is present (lines 830–840). When the cache doesn't resolve an ID (profileId === profileName), it fetches the full profile list from the machine and resolves by name.
| <Card | ||
| className="p-2 sm:p-3 transition-colors cursor-pointer hover:bg-secondary/40" | ||
| onClick={() => onUseProfile?.(rec.profile_name)} | ||
| role="button" | ||
| tabIndex={0} | ||
| onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onUseProfile?.(rec.profile_name) } }} | ||
| aria-label={t('a11y.useProfile', { name: rec.profile_name })} | ||
| > |
There was a problem hiding this comment.
This Card is always rendered as an interactive control (role="button", tabIndex=0, aria-label, pointer cursor) even when onUseProfile is undefined, in which case it becomes a focusable element that does nothing. Make the interactivity conditional (only add onClick/keyboard handlers/role/tabIndex/aria-label/cursor-pointer when onUseProfile is provided) to avoid confusing keyboard/screen-reader users.
There was a problem hiding this comment.
Already handled in the current code. The Card's interactive props (role, tabIndex, aria-label, onClick, onKeyDown, and cursor-pointer) are conditionally spread via {...(onUseProfile ? { ...interactiveProps } : {})} — they are only applied when onUseProfile is provided.
| <Card | ||
| className="p-2 sm:p-3 transition-colors cursor-pointer hover:bg-secondary/40" | ||
| onClick={() => handleSelect(rec.profile_name)} | ||
| role="button" | ||
| tabIndex={0} | ||
| onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSelect(rec.profile_name) } }} | ||
| aria-label={t('a11y.useProfile', { name: rec.profile_name })} | ||
| > |
There was a problem hiding this comment.
Similar to ProfileRecommendations, this Card is always focusable/keyboard-clickable even when onSelectProfile is undefined. Either make onSelectProfile required here, or conditionally add role/tabIndex/handlers/aria-label only when a selection callback is present.
There was a problem hiding this comment.
Already addressed — same conditional spread pattern as ProfileRecommendations. The Card only gets interactive attributes when onSelectProfile is provided.
| switch (soundAttr) { | ||
| case 'back': | ||
| case 'close': | ||
| tiks.notify() | ||
| tiks.click() | ||
| break |
There was a problem hiding this comment.
The header docs for useGlobalSoundDelegation still say data-sound "back"/"close" map to notify, but the implementation now plays tiks.click() for those cases. Update the documentation block to match the new behavior (or switch back to notify if that's what was intended).
There was a problem hiding this comment.
Fixed — updated the file-level JSDoc to say "back", "close" → click to match the actual tiks.click() implementation. The function-level JSDoc was already correct.
| const tagToTechnique: Record<string, string> = { | ||
| preinfusion: 'preinfusion', | ||
| 'pre-infusion': 'preinfusion', | ||
| bloom: 'bloom', | ||
| soak: 'bloom', | ||
| pulse: 'pulse', | ||
| lever: 'lever', | ||
| turbo: 'turbo', | ||
| ramp: 'ramp', | ||
| decline: 'decline', | ||
| taper: 'taper', | ||
| flat: 'flat', | ||
| pressure: 'pressure-profile', | ||
| flow: 'flow-profile', | ||
| } |
There was a problem hiding this comment.
New preset tags like "Pressure-controlled" / "Flow-controlled" / "Mixed-controlled" are added in tags.ts, but buildUserFingerprint() only maps "pressure" and "flow" to control-mode techniques. As a result, selecting these new technique tags in the UI won't influence control-mode scoring in getRecommendations(). Add mappings for the new labels (and/or normalize tags up-front) so user-selected technique tags affect the synthetic fingerprint as intended.
There was a problem hiding this comment.
Fixed. The tag label mappings were already present in tagToTechnique, but the controlMode assignment was missing the mixed case. Added else if (techniqueTags.has('mixed-profile')) controlMode = 'mixed' so the Mixed-controlled tag now correctly influences control-mode scoring.
| // Merge structural technique tags into candidate tags for broader matching | ||
| const allCandTags = new Set([...candTags, ...candFp.techniqueTags]) | ||
| if (userLower.size > 0) { | ||
| const tagSim = jaccard(userLower, allCandTags) | ||
| const tagPts = tagSim * 25 | ||
| score += tagPts | ||
|
|
||
| const overlap: string[] = [] | ||
| for (const t of userLower) { | ||
| if (allCandTags.has(t)) overlap.push(t) | ||
| } | ||
| if (overlap.length > 0) { | ||
| // Filter out already-reported technique tags | ||
| const userTechTags = userFingerprint?.techniqueTags ?? new Set<string>() |
There was a problem hiding this comment.
Tag similarity uses candFp.techniqueTags directly (e.g. "pressure-profile"/"flow-profile" internal tokens) which won't match the new user-facing preset labels (e.g. "Pressure-controlled") or temperature-range labels. Consider normalizing candidate structural features into the same vocabulary as PRESET_TAGS (e.g. add ${controlMode}-controlled and temperatureRange(temp) to allCandTags, and/or map internal technique tags to user-facing labels) so tag-based recommendations actually respond to those selections.
| // Merge structural technique tags into candidate tags for broader matching | |
| const allCandTags = new Set([...candTags, ...candFp.techniqueTags]) | |
| if (userLower.size > 0) { | |
| const tagSim = jaccard(userLower, allCandTags) | |
| const tagPts = tagSim * 25 | |
| score += tagPts | |
| const overlap: string[] = [] | |
| for (const t of userLower) { | |
| if (allCandTags.has(t)) overlap.push(t) | |
| } | |
| if (overlap.length > 0) { | |
| // Filter out already-reported technique tags | |
| const userTechTags = userFingerprint?.techniqueTags ?? new Set<string>() | |
| const techniqueTagMap = new Map<string, string>([ | |
| ['pressure-profile', 'pressure-controlled'], | |
| ['flow-profile', 'flow-controlled'], | |
| ]) | |
| const normalizedTechniqueTags = [...candFp.techniqueTags].map(tag => | |
| (techniqueTagMap.get(tag.toLowerCase()) ?? tag).toLowerCase(), | |
| ) | |
| const normalizedCandTags = [...candTags].map(tag => tag.toLowerCase()) | |
| const derivedCandTags = new Set<string>([ | |
| ...normalizedCandTags, | |
| ...normalizedTechniqueTags, | |
| ]) | |
| if (candFp.controlMode) { | |
| derivedCandTags.add(`${candFp.controlMode.toLowerCase()}-controlled`) | |
| } | |
| if (candTemp != null) { | |
| derivedCandTags.add(temperatureRange(candTemp).toLowerCase()) | |
| } | |
| if (userLower.size > 0) { | |
| const tagSim = jaccard(userLower, derivedCandTags) | |
| const tagPts = tagSim * 25 | |
| score += tagPts | |
| const overlap: string[] = [] | |
| for (const t of userLower) { | |
| if (derivedCandTags.has(t)) overlap.push(t) | |
| } | |
| if (overlap.length > 0) { | |
| // Filter out already-reported technique tags | |
| const userTechTags = new Set( | |
| [...(userFingerprint?.techniqueTags ?? new Set<string>())].map(tag => | |
| (techniqueTagMap.get(tag.toLowerCase()) ?? tag).toLowerCase(), | |
| ), | |
| ) |
There was a problem hiding this comment.
Already addressed. The scoring function normalizes internal technique tags to user-facing labels via techniqueToLabel (lines 149–156), and adds derived ${controlMode}-controlled and temperature-range tags to allCandTags (lines 157–168).
| let limit = 10 | ||
| const body = init?.body | ||
| if (body instanceof FormData) { | ||
| profileName = body.get('profile_name')?.toString() ?? '' | ||
| limit = parseInt(body.get('limit')?.toString() ?? '10', 10) |
There was a problem hiding this comment.
limit is parsed from FormData without validation. If the client sends a non-numeric or negative value, parseInt() can yield NaN/negative and slice(0, limit) can behave unexpectedly (e.g. negative limit returns all-but-last). Clamp to a sane positive integer (and optionally a max) before passing to the scoring functions.
| let limit = 10 | |
| const body = init?.body | |
| if (body instanceof FormData) { | |
| profileName = body.get('profile_name')?.toString() ?? '' | |
| limit = parseInt(body.get('limit')?.toString() ?? '10', 10) | |
| const DEFAULT_LIMIT = 10 | |
| const MIN_LIMIT = 1 | |
| const MAX_LIMIT = 100 | |
| let limit = DEFAULT_LIMIT | |
| const body = init?.body | |
| if (body instanceof FormData) { | |
| profileName = body.get('profile_name')?.toString() ?? '' | |
| const parsedLimit = parseInt(body.get('limit')?.toString() ?? `${DEFAULT_LIMIT}`, 10) | |
| if (Number.isFinite(parsedLimit)) { | |
| limit = Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, parsedLimit)) | |
| } |
There was a problem hiding this comment.
Already addressed. Both endpoints validate/clamp limit with Number.isFinite(parsed) guard and Math.min(100, Math.max(1, parsed)), defaulting to 10 or 5 when parsing fails.
| const res = await _fetch('/api/v1/profile/list') | ||
| if (!res.ok) return jsonResponse({ recommendations: [] }) | ||
| const profiles: AnalyzableProfile[] = await res.json() | ||
| if (!profiles?.length) return jsonResponse({ recommendations: [] }) |
There was a problem hiding this comment.
Both recommendation endpoints fetch /api/v1/profile/list on every request. This can get chatty because the UI debounces but still refetches on each tag change. Since this interceptor already maintains a profile list cache (localStorage + _profileCache), consider reusing it and/or adding a short TTL in-memory cache for the full list used by the scorer to reduce repeated machine calls.
There was a problem hiding this comment.
Already addressed — a short-lived in-memory cache (_getCachedProfileList() with 30-second TTL) was added so both endpoints share cached data and avoid repeated machine calls on rapid tag changes.
- App.tsx: handleViewProfileByName now falls back to /api/v1/profile/list when cache doesn't resolve a profile ID (prevents broken navigation) - FindSimilarOverlay/ProfileRecommendations: Card interactivity (role/tabIndex/handlers) is now conditional on callback being provided - useSoundEffects: update docs to match implementation (back/close → click) - profileRecommendation: add preset tag mappings for *-controlled variants, normalize internal technique tags to user-facing labels, add derived control-mode and temperature-range tags for better tag matching - DirectModeInterceptor: validate/clamp limit parameter, add 30s TTL in-memory cache for profile list to reduce repeated machine fetches Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hessius
left a comment
There was a problem hiding this comment.
Addressed all 8 review findings in commit 632def7:
-
App.tsx fallback — Added fallback to fetch
/api/v1/profile/listand resolve profile ID by name when cache miss occurs. Now handles both cache-hit and cache-miss paths. -
ProfileRecommendations conditional interactivity — Card
role,tabIndex, keyboard handlers, andaria-labelare now only applied whenonUseProfilecallback is provided. No interactive ARIA attributes when non-clickable. -
FindSimilarOverlay conditional interactivity — Same treatment applied. Interactive attributes conditional on
onSelectProfilebeing provided. -
useSoundEffects docs — Updated header docs:
back/close→click()(was documented asnotifybut intentionally changed per user feedback to match forward navigation sound). -
buildUserFingerprint mappings — Added
pressure-controlled,flow-controlled,mixed-controlled→ internal technique tag mappings so PRESET_TAGS selections properly influence scoring. -
Tag vocabulary normalization — Internal technique tags (
pressure-profile,flow-profile, etc.) are now normalized to user-facing labels before Jaccard comparison. Also added derived control-mode and temperature-range tags to candidate tag set for broader matching. -
Limit validation — Both find-similar and recommend endpoints now validate/clamp
limitto[1, 100]withNumber.isFinite()guard. -
Profile list caching — Added 30-second TTL in-memory cache via
_getCachedProfileList(). Both recommendation endpoints now use this cached helper instead of fetching on every request.
The E2E tests were consistently failing because the app defaulted to proxy mode in CI (no backend API server), causing initialization to block indefinitely on /api/history fetches. In direct mode, the app skips initialization and renders immediately, allowing tests to pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Button text changes: 'Profile Catalogue' → 'Profiles', 'Run Schedule' → 'Run Shot' - Navigation flow: 'Add Profile' now opens ProfileImportDialog before form - Page title: 'Metic.' (static in index.html, no dynamic update) - Settings: About section is now collapsible, auto-save replaces Save button - Header h1 only renders on home view (isHome guard) - Add shared navigateToForm() and isAiAvailableInForm() helpers - Revert VITE_MACHINE_MODE=direct from CI (broke base path) - Fix Dynamic Island logo button missing aria-label when collapsed - Exclude nested-interactive axe rule for settings (links inside buttons) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix useSoundEffects.ts JSDoc: back/close → click (matches tiks.click()) - Add mixed-profile control mode mapping in buildUserFingerprint() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Port the Python recommendation engine to TypeScript so "Find Similar Profiles" and tag-based recommendations work in the native iOS app (Capacitor/direct mode) without requiring the Python backend.
Problem
In Capacitor/direct mode,
DirectModeInterceptorstubbed both/api/profiles/find-similarand/api/profiles/recommendwith empty{ recommendations: [] }responses. The feature was flagged as enabled infeatureFlags.tsbut silently returned no results.Solution
New files (1,092 lines added)
profileAnalysis.ts— Unified profile structural analysis module. Faithful TypeScript port of Python_extract_fingerprint()and_extract_name_tags()fromprofile_recommendation_service.py. Extracts control mode, technique tags, peak pressure, temperature, weight, and structural features from profile stages.profileRecommendation.ts— Client-side scoring engine. Same algorithm as Python backend: structure (35%), tags (25%), weight (15%), pressure (15%), temperature (10%). Pure functions, no caching. ExportsfindSimilarProfiles()andgetRecommendations().profileAnalysis.test.ts+profileRecommendation.test.ts— 65 parity tests using the same 5 fixture profiles as Pythontest_recommendations.py. Validates fingerprint extraction, name tag extraction, scoring, ranking, and edge cases.Modified files
DirectModeInterceptor.ts— Replaced empty stubs with client-side engine calls. On recommendation requests, fetches full profile list from machine via/api/v1/profile/list, runs scoring, returns results.Testing
Related