Skip to content

feat: Derive structural tags for profile catalogue filtering#402

Merged
hessius merged 5 commits intoversion/2.4.0from
feat/catalogue-derived-tags
Apr 27, 2026
Merged

feat: Derive structural tags for profile catalogue filtering#402
hessius merged 5 commits intoversion/2.4.0from
feat/catalogue-derived-tags

Conversation

@hessius
Copy link
Copy Markdown
Owner

@hessius hessius commented Apr 27, 2026

Summary

Expands profile catalogue structural tags with content-based analysis of stage dynamics, weights, and variable references. All tags work in both proxy (Python) and direct (Capacitor) mode.

New Tag Categories

Weight Range (from final_weight)

  • Ristretto (≤35g), Normale (36–44g), Lungo (45–54g), Allongé (55g+)

Pressure Range (from dynamics + limits peak pressure)

  • Low (≤4 bar), Medium (5–7 bar), Standard (8–9 bar), High (10+ bar)

Structural Bloom (content-based, not name-based)

  • Zero-flow stages with time exit (thermal soak)
  • Low-power stages with time exit (pump-off bloom)

Structural Pre-infusion (content-based)

  • Power-type first stage (pump fill)
  • Low-pressure (≤4 bar) or low-flow (≤2 ml/s) first stage

Adaptive/Parametric (from $variable references)

  • Profiles with tunable variables in dynamics, limits, or exit triggers

Name-based Fallback (for partial profiles without stages)

  • Bloom/pre-infusion detection from profile name when stages unavailable

Files Changed

  • profileAnalysis.ts — Expanded fingerprint extraction, added weightRange(), pressureRange(), structural detection
  • profileAnalysis.test.ts — 60 tests (up from 39)
  • tags.ts — Added weight/pressure entries to PRESET_TAGS + color mappings
  • index.csstag-weight (fuchsia) and tag-pressure (red) CSS classes
  • profileRecommendation.ts — Updated fallback fingerprint for new fields
  • profiles.py — Python backend parity for all new tag types
  • test_main.py — 3 new Python tests for weight, adaptive, bloom tags

Tests

  • 866 TypeScript tests pass (all 38 test files)
  • All Python tests pass including 3 new derived tag tests
  • CI: All 6 jobs green ✅

Closes #398

Add deriveStructuralTags() to profileAnalysis.ts that maps profile
fingerprints to PRESET_TAG labels (technique, temperature range).

Backend: Add derived_tags to /api/machine/profiles response using
_extract_fingerprint() on profile objects. Handles happy path,
partial fallback, and offline/history fallback with dict-to-namespace
conversion.

DirectModeInterceptor: Derive tags in _processProfileList() before
stripping stage data.

ProfileCatalogueView: Merge derived_tags with user_preferences tags
for both filtering and display. All profiles now show technique and
temperature tags even without AI-generated descriptions.

Includes 14 new tests for deriveStructuralTags() and temperatureRange(),
plus 2 backend tests for derived_tags in profile list responses.

Closes #398

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

Adds structurally-derived, user-facing tags (technique + temperature range) so the profile catalogue can filter and display useful tags even when AI/user overlay tags are absent.

Changes:

  • Introduces deriveStructuralTags() and centralizes temperatureRange() in profileAnalysis.ts, with accompanying unit tests.
  • Extends server /api/machine/profiles to include derived_tags across online, partial, and offline/history response paths.
  • Updates direct mode interception and the profile catalogue UI to merge derived_tags with preference-derived tags for filtering/display.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
apps/web/src/services/interceptor/DirectModeInterceptor.ts Adds derived_tags when adapting /api/v1/profile/list into /api/machine/profiles in direct mode.
apps/web/src/lib/profileRecommendation.ts Imports shared temperatureRange() from profileAnalysis.ts (removes duplicate implementation).
apps/web/src/lib/profileAnalysis.ts Adds exported temperatureRange() and new deriveStructuralTags() mapping fingerprint technique/temp to PRESET_TAG labels.
apps/web/src/lib/profileAnalysis.test.ts Adds unit tests for temperatureRange() and deriveStructuralTags().
apps/web/src/components/ProfileCatalogueView.tsx Merges derived_tags with preference tags for available-tags computation, filtering, and badge display.
apps/server/api/routes/profiles.py Computes and returns derived_tags for machine profile list responses (including offline/history fallback).
apps/server/test_main.py Adds/extends tests asserting derived_tags presence and basic expected derived-tag content.

Comment on lines +93 to +96
try:
fp = _extract_fingerprint(profile_obj)
except Exception:
return []
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

_derive_structural_tags() swallows all exceptions and returns an empty list, which can mask real regressions in _extract_fingerprint (and makes it hard to diagnose why tags disappeared). Consider logging the exception (at least at debug/warning) and/or narrowing the except clause to expected error types.

Copilot uses AI. Check for mistakes.
Comment on lines +349 to +353
it('returns only flat + temperature for empty profile (no stages, no temperature)', () => {
const profile: AnalyzableProfile = { name: 'Empty', stages: [] }
const tags = deriveStructuralTags(profile)
// 0 stages: isFlat stays true (initial value), no temperature
expect(tags).toEqual(['Flat profile'])
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

The test title says "flat + temperature" but the fixture has no temperature and the assertion expects only ['Flat profile']. Please update the test name to match the scenario to avoid confusion when failures occur.

Copilot uses AI. Check for mistakes.
Comment on lines 47 to 50
in_history: true,
has_description: !!(p.display?.description || p.display?.shortDescription),
derived_tags: deriveStructuralTags(p as unknown as AnalyzableProfile),
}))
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

deriveStructuralTags(p as unknown as AnalyzableProfile) relies on a double-cast that defeats type-checking and can hide shape mismatches from /api/v1/profile/list. Consider changing _processProfileList to accept AnalyzableProfile[] (or making CachedProfile extend/align with AnalyzableProfile) so this call is type-safe, or derive tags only when the required fields (e.g. stages) are present.

Copilot uses AI. Check for mistakes.
Comment thread apps/server/api/routes/profiles.py Outdated
from services.cache_service import _get_cached_image, _set_cached_image
from services.gemini_service import get_vision_model, PROFILING_KNOWLEDGE
from services.profile_recommendation_service import recommendation_service
from services.profile_recommendation_service import recommendation_service, _extract_fingerprint
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

Importing _extract_fingerprint (a private underscored helper) from profile_recommendation_service creates a tight coupling between the API route and an internal implementation detail. Prefer exposing a public helper (e.g. extract_fingerprint) or moving structural-tag derivation behind a public method on recommendation_service so future refactors don’t silently break this endpoint.

Copilot uses AI. Check for mistakes.
- Make extract_fingerprint public (was _extract_fingerprint) to avoid
  importing a private symbol across modules
- Narrow bare except clauses to (AttributeError, TypeError, KeyError)
  and add debug logging for structural tag derivation failures
- Fix misleading test name: 'flat + temperature' → 'flat tag' for the
  no-temperature test case
- Replace unsafe double-cast (as unknown as AnalyzableProfile) with
  runtime 'stages' field check in DirectModeInterceptor

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.

All 4 review findings addressed in fb9003a. See individual thread replies.

@hessius
Copy link
Copy Markdown
Owner Author

hessius commented Apr 27, 2026

Review findings addressed in fb9003a

1. Bare except in _derive_structural_tags() — Narrowed to (AttributeError, TypeError, KeyError) and added logger.debug() logging for both tag derivation functions.

2. Test name mismatch — Renamed from 'flat + temperature' to 'flat tag' to match the actual scenario (no temperature in fixture).

3. Double-cast in DirectModeInterceptor — Replaced as unknown as AnalyzableProfile with a runtime 'stages' in p guard. Tags are only derived when profile data contains stages.

4. Private import — Renamed _extract_fingerprintextract_fingerprint in profile_recommendation_service.py (now public API). Updated all imports across profiles.py, test_recommendations.py, and internal service usages.

hessius and others added 3 commits April 27, 2026 10:44
The Meticulous /api/v1/profile/list returns profiles without stages
data. Previously, deriveStructuralTags() required stages to produce any
tags, so in direct mode all profiles had empty derived_tags and the
catalogue filter panel never appeared.

Now deriveStructuralTags() gracefully handles missing stages:
- stages: undefined → only temperature tags (from profile.temperature)
- stages: [] → treated as flat profile (intentional empty)
- stages: [...] → full fingerprint analysis (unchanged)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ve detection

Add content-based tag derivation from stage dynamics (not names):
- Weight range: Ristretto (≤35g), Normale (36-44g), Lungo (45-54g), Allongé (55g+)
- Pressure range: Low (≤4), Medium (5-7), Standard (8-9), High (10+) bar
- Structural bloom: zero-flow/low-power stages with time exit
- Structural pre-infusion: power-type fill or low-pressure/flow first stages
- Adaptive: profiles with $variable references in dynamics/limits/exits
- Name-based fallback: bloom/pre-infusion from profile name for partial profiles

All new tags work in both proxy (Python) and direct (Capacitor) mode.
New PRESET_TAGS categories: weight (fuchsia) and pressure (red).
60 TypeScript tests (up from 39), 866 total web tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update existing test expectations for new weight/pressure tags.
Add tests for Ristretto weight range, Adaptive detection from
$variable references, and structural bloom from zero-flow stages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@hessius hessius merged commit b61d633 into version/2.4.0 Apr 27, 2026
6 checks passed
@hessius hessius deleted the feat/catalogue-derived-tags branch April 27, 2026 09:47
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