Skip to content

feat: Client-side profile recommendation engine for Capacitor/direct mode#401

Merged
hessius merged 12 commits intoversion/2.4.0from
feat/client-side-recommendations
Apr 27, 2026
Merged

feat: Client-side profile recommendation engine for Capacitor/direct mode#401
hessius merged 12 commits intoversion/2.4.0from
feat/client-side-recommendations

Conversation

@hessius
Copy link
Copy Markdown
Owner

@hessius hessius commented Apr 26, 2026

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, DirectModeInterceptor stubbed both /api/profiles/find-similar and /api/profiles/recommend with empty { recommendations: [] } responses. The feature was flagged as enabled in featureFlags.ts but 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() from profile_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. Exports findSimilarProfiles() and getRecommendations().

  • profileAnalysis.test.ts + profileRecommendation.test.ts — 65 parity tests using the same 5 fixture profiles as Python test_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

  • ✅ 786 frontend tests pass (65 new + 721 existing)
  • ✅ Build succeeds
  • ✅ App built and deployed to iPhone 14

Related

hessius and others added 6 commits April 26, 2026 21:19
…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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DirectModeInterceptor to serve /api/profiles/find-similar and /api/profiles/recommend using 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.

Comment thread apps/web/src/App.tsx Outdated
Comment on lines +819 to +832
// 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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +216 to +223
<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 })}
>
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 180 to 187
<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 })}
>
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed — same conditional spread pattern as ProfileRecommendations. The Card only gets interactive attributes when onSelectProfile is provided.

Comment on lines 143 to 147
switch (soundAttr) {
case 'back':
case 'close':
tiks.notify()
tiks.click()
break
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +215 to +229
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',
}
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +148 to +161
// 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>()
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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(),
),
)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +1938 to +1942
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)
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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))
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1945 to +1948
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: [] })
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Copy Markdown
Owner Author

@hessius hessius left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed all 8 review findings in commit 632def7:

  1. App.tsx fallback — Added fallback to fetch /api/v1/profile/list and resolve profile ID by name when cache miss occurs. Now handles both cache-hit and cache-miss paths.

  2. ProfileRecommendations conditional interactivity — Card role, tabIndex, keyboard handlers, and aria-label are now only applied when onUseProfile callback is provided. No interactive ARIA attributes when non-clickable.

  3. FindSimilarOverlay conditional interactivity — Same treatment applied. Interactive attributes conditional on onSelectProfile being provided.

  4. useSoundEffects docs — Updated header docs: back/closeclick() (was documented as notify but intentionally changed per user feedback to match forward navigation sound).

  5. buildUserFingerprint mappings — Added pressure-controlled, flow-controlled, mixed-controlled → internal technique tag mappings so PRESET_TAGS selections properly influence scoring.

  6. 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.

  7. Limit validation — Both find-similar and recommend endpoints now validate/clamp limit to [1, 100] with Number.isFinite() guard.

  8. 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.

hessius and others added 4 commits April 27, 2026 00:14
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>
@hessius hessius merged commit eb4f5d0 into version/2.4.0 Apr 27, 2026
6 checks passed
@hessius hessius deleted the feat/client-side-recommendations branch April 27, 2026 05:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants