Skip to content

feat(home): rebuild personalization on exact-token learning and candidates/v1 - #941

Closed
ashwkun wants to merge 1 commit into
masterfrom
feat/home-personalization-rebuild
Closed

feat(home): rebuild personalization on exact-token learning and candidates/v1#941
ashwkun wants to merge 1 commit into
masterfrom
feat/home-personalization-rebuild

Conversation

@ashwkun

@ashwkun ashwkun commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

  • Rebuild Home Taste / Because You Like / greeting discovery around one catalog coordinator on /home/candidates/v1, with a durable cold-start mode machine and learner-selected BYL anchors.
  • Fix learning attribution with exact exposure tokens (Room v2), long-press feedback (More like this / Not for me / Hide this show), and playback carry-through so outcomes reinforce the right card.
  • Add observational Home slate + attribution-health analytics, locale-aware recommendation languages, and bounded sync backlog/age logging.

Motivation

Home discovery felt poorly personalized because learning often attributed the wrong impression and rails used split heuristics. This rebuild makes attribution exact, unifies slate allocation, and makes quality measurable before expanding personalization elsewhere.

What changed

  • Ranking: exact-token exposures, outcome ledger, hard show exclusions, proportional facet credit, new feedback actions, Room migration v2.
  • Network/catalog: /home/candidates/v1 DTOs + Retrofit; HomePersonalizationCoordinator (seeds, mode, allocation, ≥4h cache); region→language mapping.
  • Home UI: slate-driven Taste/BYL/mission, mode-honest titles, mission rotation, accessible recommendation feedback, one-time personalization-state reset (history/subs/queue/likes preserved).
  • Observability: home_slate_quality_snapshot + home_learning_attribution_health (aggregate/privacy-safe).
  • Inventory: remove English-only v2 language hardcoding; sync backlog/age health logging.

Behavior & compatibility

  • Older recommendation endpoints remain; Android adds /home/candidates/v1 consumption.
  • Cold start shows honest “Popular in your Region” until meaningful listening unlocks personalization; BYL stays hidden until PERSONALIZED.
  • Adaptive ranking remains Home-default only (RankingRolloutPolicy); quality gates are observational dashboards, not kill switches.
  • First launch of the new path clears personalization caches/models only.

Impact (required)

User impact — pick exactly one

  • user-impact-high
  • user-impact-medium
  • user-impact-low
  • no-user-impact

Listener impact

What changes in the user’s life:
Home recommendations should feel more like their taste: clearer “Based on Your Taste” vs regional popular, Because You Like anchored to a real favorite (with a change-anchor option), a single rotating discovery mission under the greeting, and long-press ways to say more like this / not for me / hide this show so the feed improves from their feedback.

Backend

  • backend-change (sync health logging / workflow notes; consumes already-deployed candidates API)

Test plan

  • ./gradlew testDebugUnitTest detekt ktlintCheck --continue green locally
  • ./gradlew installDebug on connected device
  • Cold start / few meaningful plays: Regional label, BYL hidden
  • After 2–3 meaningful plays: Taste personalizes; BYL appears when eligible anchor exists
  • Change BYL anchor; only BYL refresh; manual select trains learner
  • Long-press More like this / Not for me / Hide this show across Taste, BYL, mission
  • Play from a recommended card; background/continue listening still attributes correctly
  • Offline / failed candidates request: retain regional slate, retry without flicker
  • Confirm analytics glossary events emit in debug without PII titles

…dates/v1

Replace split Taste/BYL heuristics with one catalog coordinator, cold-start mode machine, learner anchors, greeting missions, and recommendation feedback so Home learning attributes the right card and stays measurable.
@ashwkun ashwkun added user-impact-high Listeners clearly notice this change — prioritize README and notification backend-change Touches backend/proxy/infra — pairable with any user-impact level labels Jul 22, 2026
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Introduced a personalized Home feed with Taste, Because You Like, and rotating Discovery Mission sections.
    • Added cross-section deduplication, caching, regional language support, and improved recommendation anchoring.
    • Added long-press feedback actions, including “More like this,” “Not for me,” and “Hide this show.”
    • Preserved recommendation attribution through playback interactions.
  • Bug Fixes

    • Recommendations now use country-appropriate languages instead of English-only requests.
  • Documentation

    • Updated guidance for Home personalization, analytics, and recommendation behavior.

Walkthrough

This PR adds a Home candidates-v1 personalization pipeline with cached retrieval, mode and mission selection, cross-rail allocation, feedback attribution, ranking persistence, and aggregate quality diagnostics. It also wires the pipeline into Home UI and updates related network, analytics, documentation, tooling, and tests.

Changes

Home candidate pipeline

Layer / File(s) Summary
Candidate contracts and retrieval
core/network/..., core/catalog/...
Adds Home candidates-v1 DTOs, API access, request building, response validation, four-hour caching, mapping, and country-based language selection.
Personalization logic and slate construction
core/catalog/src/main/java/.../home/*
Adds personalization modes, meaningful-play gating, anchor and mission selection, cross-module de-duplication, slate quality computation, and HomePersonalizationCoordinator.
Home integration and feedback UI
feature/home/src/main/java/..., app/src/main/java/...
Loads slates through the coordinator, exposes mission and mode state, records exposures, supports long-press feedback, and wires dependencies through the app container.
Ranking attribution and persistence
core/ranking/src/main/java/...
Adds exact exposure-token resolution, outcome finalization, hard show exclusions, facet queries, backup version 2, and Room migration support.
Analytics diagnostics
core/analytics/..., core/model/..., docs/ANALYTICS_EVENT_GLOSSARY.md
Adds aggregate Home slate-quality and exposure-health telemetry models and Phase D analytics events.
Supporting updates
core/designsystem/..., scripts/sync/..., config/..., docs/...
Adds expressive clickable overloads, sync backlog reporting, lint baseline updates, startup diagnostics documentation, and architecture documentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HomeViewModel
  participant HomePersonalizationCoordinator
  participant PodcastRepository
  participant AdaptiveRankingRepository
  participant AnalyticsHelper
  HomeViewModel->>AdaptiveRankingRepository: resolve anchor and exclusions
  HomeViewModel->>HomePersonalizationCoordinator: loadSlate
  HomePersonalizationCoordinator->>PodcastRepository: getHomeCandidatesV1
  PodcastRepository-->>HomePersonalizationCoordinator: cached or fetched candidates
  HomePersonalizationCoordinator-->>HomeViewModel: SlateResult and quality telemetry
  HomeViewModel->>AdaptiveRankingRepository: record rail exposures
  HomeViewModel->>AnalyticsHelper: trackHomeSlateQualitySnapshot
Loading

Possibly related PRs


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (4 errors, 1 warning)

Check name Status Explanation Resolution
Title check ❌ Error The title follows Conventional Commits, but it exceeds the ~72 character limit required by the check. Shorten the title to under ~72 characters while keeping the Conventional Commits format, e.g. feat(home): unify personalization around candidates/v1.
Unresolved Review Threads ❌ Error Multiple review threads remain open: backlog age, cache-key, coordinator merge, slate quality, cancellation, long-press label, and README/test gaps are still in code. Fix or dismiss the remaining threads: backlog age, cache key, coordinator mapping, duplicate-rate math, cancellation rethrow, episode map clearing, long-press label, README updates, and missing hermetic tests.
Module Readme Updated ❌ Error Production Kotlin changed in core/designsystem, core/playback, and core/prefs, but no matching module README.md was modified for those modules. Add the corresponding README.md updates for core/designsystem, core/playback, and core/prefs (or remove those main-source changes) in the same PR.
Jvm Tests For Changed Logic ❌ Error Helper logic is covered, but the main new ViewModel/app/prefs entry points (loadSlate/reset/startup diagnostics/sticky mission APIs) have no hermetic src/test coverage. Add JVM tests for HomePersonalizationCoordinator.loadSlate/mapResponse, HomeViewModelSlate/reset and startup diagnostics, and the new prefs APIs; cover happy/empty/failure cases.
Docstring Coverage ⚠️ Warning Docstring coverage is 13.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly matches the PR’s Home personalization, attribution, analytics, and sync changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Architecture Compliance ✅ Passed Changed files stay within allowed boundaries: no feature→feature/PostHog/Hilt-MockK issues, no catalog→designsystem/playback imports, and ranking storage has a v2 migration.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
  • 🛠️ update changelog

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
3 New issues

See analysis details on SonarQube Cloud

💡 Need a hand with PR review? Try Gitar by Sonar!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 21

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt (1)

32-155: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a focused HomeViewModel.loadData() test
The existing home tests cover DI wiring and pure helpers, but not the reworked loadData() flow in feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt. Add regression coverage for the fast bootstrap job and personalizationSlice updates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt`
around lines 32 - 155, Add focused tests for HomeViewModel.loadData(), covering
the fast bootstrap job’s successful state updates and the personalizationSlice
emissions when personalization inputs change. Reuse existing HomeViewModel test
fixtures and repository fakes, and verify the resulting observable UI/state
values without expanding coverage beyond these two loadData flows.

Source: Path instructions

core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt (1)

430-442: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Missing core/playback/README.md update for this behavioral change.

This PR adds exact-exposure-token threading (EXPOSURE_ID_KEY, exposureId in FeedbackTarget) to PlaybackTelemetrySession, a behavioral/contract change to :core:playback. No core/playback/README.md change accompanies it anywhere in this PR stack.

As per path instructions, "If any production Kotlin under core//src/main/ changed, require core//README.md updated in the same PR (unconditional)."

Also applies to: 633-637

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt`
around lines 430 - 442, Update core/playback/README.md to document the exact
exposure-token threading introduced by PlaybackTelemetrySession, including
EXPOSURE_ID_KEY and exposureId in FeedbackTarget and its role in resolving
feedback against the exact impression. Keep the documentation aligned with the
existing playback contract.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/sync-pi-data.yml:
- Line 69: Update the cache step’s actions/cache reference to use the action’s
full immutable commit SHA instead of the mutable v6 tag, while preserving the
existing cache configuration and workflow behavior.

In `@app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt`:
- Around line 174-198: Add hermetic JVM tests under src/test for
reportHomeLearningAttributionHealth, covering successful diagnostics emission
and non-cancellation exception handling while preserving cancellation
propagation and startup stability. Introduce an injectable reporter seam or
equivalent testable abstraction around
AnalyticsHelper.trackHomeLearningAttributionHealth and verify both outcomes
without relying on Android runtime behavior.

In
`@core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsTracksFacadeTest.kt`:
- Around line 462-493: Extend homeSlateQualitySnapshotEvent with a nullable
algorithmVersion case, invoking AnalyticsHelper.trackHomeSlateQualitySnapshot
with algorithmVersion set to null and asserting the emitted algorithm_version
property equals "unknown".

In
`@core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeCandidatesRequestBuilder.kt`:
- Around line 103-125: Update HomeCandidatesRequestBuilder.cacheKey to include
request.daypart and request.noveltyPreference, and encode the complete
request.excludedPodcastIds collection instead of truncating it with take(40).
Preserve the existing deterministic ordering for exclusions and all other key
components.

In
`@core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt`:
- Around line 293-333: Update allocateModules so each allocated candidate is
resolved against the episode map for its own module instead of the merged byId
map. Build separate ID maps for taste, becauseYouLike, and mission, then use the
corresponding map when producing each AllocatedEpisodes list; preserve
allocatedCandidates unchanged.
- Around line 70-127: Add coordinator-level JVM coverage in
HomePersonalizationCoordinatorTest for loadSlate and mapResponse using a
hermetic podcastRepository stub. Verify module selection, response mapping, and
cross-module podcast de-duplication, while keeping existing helper tests
unchanged.

In
`@core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeSlateQualityLogic.kt`:
- Around line 45-54: Update the duplicateRatePercent calculation in
HomeSlateQualityLogic to use an explicit duplicate-removal count from the
allocation flow, rather than allocatablePool minus allocated.size, so limit and
eligibility exclusions are not counted as duplicates. Propagate or compute that
count from raw candidate identities before those exclusions, and add a
regression test covering 30 unique candidates producing a 12-item slate with no
duplicates reported.

In
`@core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryHomeCandidates.kt`:
- Around line 14-38: Prevent unbounded growth of homeCandidatesCache by removing
expired entries during the cache write path in getHomeCandidatesV1. Before or
while inserting a new response, sweep entries whose cached timestamp is older
than HomeCandidatesRequestBuilder.CACHE_TTL_MILLIS, while preserving valid
entries and existing cache-read behavior.

In
`@core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/ExpressiveMotion.kt`:
- Around line 118-199: Update the core/designsystem module README to document
the stable expressiveClickable modifier overloads, including click behavior,
optional shape/indication configuration, and the long-click overload’s
onLongClickLabel accessibility announcement. Follow the existing module README
template and describe the new expressive press and long-press behavior without
changing the Kotlin implementation.

In
`@core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt`:
- Around line 633-637: Remove the duplicate EXPOSURE_ID_KEY declaration from the
PlaybackTelemetrySession companion object and update its usages to reference the
existing shared definition across the module boundary, preserving the same
"exposure_id" key behavior.

In `@core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt`:
- Around line 133-151: The preference change introduces
clearRecommendationCaches, but the corresponding core/prefs/README.md
documentation is missing. Update the README to document this method, the
recommendation cache keys it clears, and that it preserves history,
subscriptions, queue, and likes.

In `@core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt`:
- Around line 40-46: Update core/prefs/README.md to document the newly added
UserPreferenceKeys entries STICKY_MISSION_ID, STICKY_MISSION_SLOT_KEY, and
FIRST_LAUNCH_PERSONALIZATION_RESET_DONE, including their preference keys and
intended usage consistent with the existing README conventions.

In
`@core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt`:
- Around line 1017-1043: Update core/prefs/README.md to document the new sticky
mission preference streams and setStickyMission function, including their
persisted mission ID and slot-key behavior. Keep the documentation aligned with
the public symbols stickyMissionIdStream, stickyMissionSlotKeyStream, and
setStickyMission.
- Around line 1045-1053: Update
UserPreferencesRepository.hasCompletedFirstLaunchPersonalizationReset() to read
dataStore.data through the same IOException-safe catch-and-emptyPreferences flow
used by other getters in the file before taking first() and accessing
Keys.FIRST_LAUNCH_PERSONALIZATION_RESET_DONE. Preserve propagation of
non-IOException failures while preventing recoverable DataStore I/O errors from
escaping the initial reset check.

In
`@core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/database/AdaptiveRankingDao.kt`:
- Around line 166-167: Wire AdaptiveRankingDao.pruneOutcomesBefore() into the
same ranking retention flow that invokes pruneExposures, using the retention
cutoff so old ranking_outcomes are deleted alongside exposures; otherwise remove
pruneOutcomesBefore() if indefinite outcome retention is intended.

In
`@core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/GlossaryCoverageGuardTest.kt`:
- Around line 97-102: Update the phase extraction validation in the test’s
glossary parsing helper to require phaseD.isNotEmpty() alongside phaseAUnionB
and phaseC. Include PHASE_D in the existing parse-failure message so missing or
renamed markers produce a specific error before returning the combined set.

In
`@feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/PodcastCard.kt`:
- Around line 52-57: Update the onLongClickLabel in the PodcastCard call to use
the same plus-to-space title transformation as the displayed title, so TalkBack
announces the formatted podcast title consistently.

In
`@feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt`:
- Around line 61-77: Update the exception handling in the coroutine launched by
the home data-loading flow to catch CancellationException before the general
Exception handler and immediately rethrow it. Keep logging non-cancellation
bootstrap failures through the existing BoxCastTiming Log.e call, while
preserving the existing state updates and cleanup behavior for handled
exceptions.

In
`@feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSlate.kt`:
- Around line 359-376: Replace the locally duplicated EXPOSURE_ID_KEY string in
HomeViewModel.buildExposureSourceContext with the shared playback telemetry
exposure-key symbol or centralized contract, updating the Bundle write
accordingly while preserving the existing exposure token value and entry-point
behavior.
- Around line 220-262: Clear episodeExposureIds at the start of
recordSlateExposures before launching the exposure-recording work, then
repopulate it from the current taste, becauseYouLike, and mission lists.
Preserve the existing recordRailExposures behavior and ensure stale mappings
from prior slate reloads are removed.

In `@scripts/sync/03-sync-episodes.js`:
- Around line 117-120: Update oldestDeferredAgeHours and the related backlog
messages at the identified reporting points to preserve an unknown age when
deferredWithHistory is empty, using a nullable or n/a representation instead of
0. Ensure messages in the deferred backlog reporting flow phrase the age as
unavailable when no deferred show has a prior-check timestamp, while retaining
numeric hour output when history exists.

---

Outside diff comments:
In
`@core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt`:
- Around line 430-442: Update core/playback/README.md to document the exact
exposure-token threading introduced by PlaybackTelemetrySession, including
EXPOSURE_ID_KEY and exposureId in FeedbackTarget and its role in resolving
feedback against the exact impression. Keep the documentation aligned with the
existing playback contract.

In
`@feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt`:
- Around line 32-155: Add focused tests for HomeViewModel.loadData(), covering
the fast bootstrap job’s successful state updates and the personalizationSlice
emissions when personalization inputs change. Reuse existing HomeViewModel test
fixtures and repository fakes, and verify the resulting observable UI/state
values without expanding coverage beyond these two loadData flows.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 050f793b-8719-460f-86c8-0078742d6972

📥 Commits

Reviewing files that changed from the base of the PR and between 77cf95c and c138030.

⛔ Files ignored due to path filters (2)
  • docs/analytics/event_glossary.csv is excluded by !**/*.csv
  • docs/analytics/glossary_emission_coverage.csv is excluded by !**/*.csv
📒 Files selected for processing (102)
  • .github/workflows/sync-pi-data.yml
  • app/README.md
  • app/src/main/java/cx/aswin/boxlore/AppContainer.kt
  • app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt
  • app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt
  • config/detekt/baseline.xml
  • config/ktlint/app-baseline.xml
  • config/ktlint/core-catalog-baseline.xml
  • config/ktlint/feature-home-baseline.xml
  • core/analytics/README.md
  • core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/Analytics.kt
  • core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt
  • core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt
  • core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/HomeQualityAnalyticsTracks.kt
  • core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/RecordingAnalytics.kt
  • core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsTracksFacadeTest.kt
  • core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryAllEventsEmissionTest.kt
  • core/catalog/README.md
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryHomeCandidates.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryRecommendations.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeAnchorSelectionLogic.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeCandidatesMapper.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeCandidatesRequestBuilder.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeDiscoveryMission.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeMeaningfulPlayLogic.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationMode.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeSlateAllocationLogic.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeSlateQualityLogic.kt
  • core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/RecommendationLanguages.kt
  • core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryRecommendationsLanguageTest.kt
  • core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/home/HomeCandidatesRequestBuilderTest.kt
  • core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationLogicTest.kt
  • core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/home/HomeSlateQualityLogicTest.kt
  • core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/ExpressiveMotion.kt
  • core/model/README.md
  • core/model/src/main/java/cx/aswin/boxlore/core/model/HomeSlateQualityTelemetry.kt
  • core/model/src/main/java/cx/aswin/boxlore/core/model/RankingExposureHealthTelemetry.kt
  • core/network/README.md
  • core/network/src/main/java/cx/aswin/boxlore/core/network/BoxLoreApi.kt
  • core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt
  • core/network/src/main/java/cx/aswin/boxlore/core/network/model/AutoTranscriptResponse.kt
  • core/network/src/main/java/cx/aswin/boxlore/core/network/model/CuratedCuriosityModels.kt
  • core/network/src/main/java/cx/aswin/boxlore/core/network/model/HomeCandidatesModels.kt
  • core/network/src/main/java/cx/aswin/boxlore/core/network/model/ITunesResponse.kt
  • core/network/src/main/java/cx/aswin/boxlore/core/network/model/OnboardingModels.kt
  • core/network/src/main/java/cx/aswin/boxlore/core/network/model/PodcastIndexModels.kt
  • core/network/src/main/java/cx/aswin/boxlore/core/network/model/SyncModels.kt
  • core/network/src/test/java/cx/aswin/boxlore/core/network/BoxLoreApiContractTest.kt
  • core/network/src/test/java/cx/aswin/boxlore/core/network/model/HomeCandidatesV1SerializationTest.kt
  • core/network/src/test/resources/fixtures/home_candidates_v1.json
  • core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt
  • core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt
  • core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt
  • core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt
  • core/ranking/README.md
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/AdaptiveCandidateScorer.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/AdaptiveLinearModel.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/AdaptiveRankingRepository.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/LearningEventLog.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/RankingExposureHealthLogic.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/RankingFeedbackRepository.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/RankingModels.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/RankingReward.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/RankingRuntimeControls.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/RankingSerialization.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/database/AdaptiveRankingDao.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/database/AdaptiveRankingDatabase.kt
  • core/ranking/src/main/java/cx/aswin/boxlore/core/ranking/database/AdaptiveRankingEntities.kt
  • core/ranking/src/test/java/cx/aswin/boxlore/core/ranking/AdaptiveRankingRepositoryTest.kt
  • core/ranking/src/test/java/cx/aswin/boxlore/core/ranking/AdaptiveRankingTest.kt
  • core/ranking/src/test/java/cx/aswin/boxlore/core/ranking/RankingExposureHealthLogicTest.kt
  • core/ranking/src/test/java/cx/aswin/boxlore/core/ranking/RankingFeedbackRepositoryTest.kt
  • core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/GlossaryCoverageGuardTest.kt
  • docs/ANALYTICS_EVENT_GLOSSARY.md
  • docs/recommendation-system.md
  • feature/home/README.md
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeDataModels.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeed.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedRecommendations.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeUiModels.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelAdaptive.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelAssembler.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelBecauseYouLike.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSlate.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/BecauseYouLikeSection.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/CuratedEpisodeCard.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ForYouSection.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/PodcastCard.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/RecommendationFeedbackMenu.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeAnchorConfidenceLogic.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeFeedbackLogic.kt
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeMissionContextLogic.kt
  • feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeViewModelConnectivityDepsTest.kt
  • feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeAnchorConfidenceLogicTest.kt
  • feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeFeedbackLogicTest.kt
  • feature/home/src/test/java/cx/aswin/boxlore/feature/home/logic/HomeMissionContextLogicTest.kt
  • scripts/sync/03-sync-episodes.js
  • scripts/sync/lib/config.js
💤 Files with no reviewable changes (1)
  • feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelBecauseYouLike.kt

# English-only) so a model change actually busts this cache instead of
# silently reusing stale weights under a mismatched name.
- name: Cache embedding model
uses: actions/cache@v6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Pin the cache action to a commit SHA.

Line 69 uses a mutable tag while this job has credentials that can push to master. Pin actions/cache to its full commit SHA.

🧰 Tools
🪛 zizmor (1.26.1)

[error] 69-69: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/sync-pi-data.yml at line 69, Update the cache step’s
actions/cache reference to use the action’s full immutable commit SHA instead of
the mutable v6 tag, while preserving the existing cache configuration and
workflow behavior.

Source: Linters/SAST tools

Comment on lines +174 to +198
/**
* Observational-only exact-exposure-token attribution health for the HOME surface
* (quality-observability). Fired once per app start, mirroring
* [reportAdaptiveRankingStatus] — this is a low-volume diagnostic aggregate, not a
* per-session or per-slate signal, so a periodic/repeating scheduler would be overkill.
*/
@Suppress("TooGenericExceptionCaught")
private fun reportHomeLearningAttributionHealth() {
applicationScope.launch {
try {
val diagnostics = container.adaptiveRankingRepository.exposureResolutionDiagnostics()
cx.aswin.boxlore.core.analytics.AnalyticsHelper
.trackHomeLearningAttributionHealth(diagnostics)
} catch (error: kotlinx.coroutines.CancellationException) {
throw error
} catch (error: Exception) {
// Attribution health is optional and must never destabilize app startup.
android.util.Log.w(
"BoxLoreApplication",
"Failed to report Home learning attribution health",
error,
)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add hermetic JVM coverage for the new startup diagnostic.

Cover successful emission and non-cancellation failure handling without destabilizing startup. Extract an injectable reporter seam if needed.

As per coding guidelines, “When a PR adds or substantially changes production logic… it must add or extend hermetic JVM unit tests.” As per path instructions, “For each substantially changed main source, check the PR also adds or extends a related *Test.kt under src/test for that behavior.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt` around lines 174 -
198, Add hermetic JVM tests under src/test for
reportHomeLearningAttributionHealth, covering successful diagnostics emission
and non-cancellation exception handling while preserving cancellation
propagation and startup stability. Introduce an injectable reporter seam or
equivalent testable abstraction around
AnalyticsHelper.trackHomeLearningAttributionHealth and verify both outcomes
without relying on Android runtime behavior.

Sources: Coding guidelines, Path instructions

Comment on lines +462 to +493
@Test
fun homeSlateQualitySnapshotEvent() {
AnalyticsHelper.trackHomeSlateQualitySnapshot(
HomeSlateQualityTelemetry(
mode = "PERSONALIZED",
algorithmVersion = "v3",
fromCache = true,
isFallback = false,
requestedModuleCount = 4,
nonEmptyModuleCount = 3,
allocatedCandidateCount = 12,
duplicateRatePercent = 10,
noveltyRatePercent = 80,
cacheAgeBucket = "under_15m",
responseLatencyBucket = "300_999ms",
),
)

assertTrue("home_slate_quality_snapshot" in names())
val props = firstProps("home_slate_quality_snapshot")
assertEquals("PERSONALIZED", props["mode"])
assertEquals("v3", props["algorithm_version"])
assertEquals(true, props["from_cache"])
assertEquals(false, props["is_fallback"])
assertEquals(4, props["requested_module_count"])
assertEquals(3, props["non_empty_module_count"])
assertEquals(12, props["allocated_candidate_count"])
assertEquals(10, props["duplicate_rate_percent"])
assertEquals(80, props["novelty_rate_percent"])
assertEquals("under_15m", props["cache_age_bucket"])
assertEquals("300_999ms", props["response_latency_bucket"])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Cover the nullable algorithm-version fallback.

This only tests a populated version; add a null case asserting algorithm_version == "unknown".

As per coding guidelines, “must add or extend hermetic JVM unit tests … covering new happy, empty, and error behavior as applicable.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsTracksFacadeTest.kt`
around lines 462 - 493, Extend homeSlateQualitySnapshotEvent with a nullable
algorithmVersion case, invoking AnalyticsHelper.trackHomeSlateQualitySnapshot
with algorithmVersion set to null and asserting the emitted algorithm_version
property equals "unknown".

Source: Coding guidelines

Comment on lines +103 to +125
fun cacheKey(request: HomeCandidatesV1Request): String =
buildString {
append(request.requestedModules.sorted().joinToString(","))
append('|')
append(request.country)
append('|')
append(request.languages.sorted().joinToString(","))
append('|')
append(request.anchorPodcastId.orEmpty())
append('|')
append(request.missionId.orEmpty())
append('|')
append(request.revision.orEmpty())
append('|')
append(request.seeds.joinToString(",") { "${it.episodeId}:${it.podcastId}:${it.weight}" })
append('|')
append(
request.excludedPodcastIds
.sorted()
.take(40)
.joinToString(","),
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Cache key omits fields that affect the request/response, risking stale/mismatched personalized results.

cacheKey() ignores daypart and noveltyPreference entirely, and truncates excludedPodcastIds to the first 40 sorted ids even though the request itself can carry up to 250 (Line 50). Two logically different requests (e.g., different daypart, or exclusions differing beyond the 40-id window) can collapse to the same key, causing a stale cached response — including one that doesn't reflect newly hard-excluded shows — to be served for up to the 4h TTL.

🛠️ Proposed fix: include full exclusion set and remaining fields in the key
     fun cacheKey(request: HomeCandidatesV1Request): String =
         buildString {
             append(request.requestedModules.sorted().joinToString(","))
             append('|')
             append(request.country)
             append('|')
             append(request.languages.sorted().joinToString(","))
             append('|')
             append(request.anchorPodcastId.orEmpty())
             append('|')
             append(request.missionId.orEmpty())
             append('|')
             append(request.revision.orEmpty())
             append('|')
+            append(request.daypart.orEmpty())
+            append('|')
+            append(request.noveltyPreference?.toString().orEmpty())
+            append('|')
             append(request.seeds.joinToString(",") { "${it.episodeId}:${it.podcastId}:${it.weight}" })
             append('|')
-            append(
-                request.excludedPodcastIds
-                    .sorted()
-                    .take(40)
-                    .joinToString(","),
-            )
+            append(request.excludedPodcastIds.sorted().joinToString(",").hashCode())
+            append('|')
+            append(request.excludedEpisodeIds.sorted().joinToString(",").hashCode())
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun cacheKey(request: HomeCandidatesV1Request): String =
buildString {
append(request.requestedModules.sorted().joinToString(","))
append('|')
append(request.country)
append('|')
append(request.languages.sorted().joinToString(","))
append('|')
append(request.anchorPodcastId.orEmpty())
append('|')
append(request.missionId.orEmpty())
append('|')
append(request.revision.orEmpty())
append('|')
append(request.seeds.joinToString(",") { "${it.episodeId}:${it.podcastId}:${it.weight}" })
append('|')
append(
request.excludedPodcastIds
.sorted()
.take(40)
.joinToString(","),
)
}
fun cacheKey(request: HomeCandidatesV1Request): String =
buildString {
append(request.requestedModules.sorted().joinToString(","))
append('|')
append(request.country)
append('|')
append(request.languages.sorted().joinToString(","))
append('|')
append(request.anchorPodcastId.orEmpty())
append('|')
append(request.missionId.orEmpty())
append('|')
append(request.revision.orEmpty())
append('|')
append(request.daypart.orEmpty())
append('|')
append(request.noveltyPreference?.toString().orEmpty())
append('|')
append(request.seeds.joinToString(",") { "${it.episodeId}:${it.podcastId}:${it.weight}" })
append('|')
append(request.excludedPodcastIds.sorted().joinToString(",").hashCode())
append('|')
append(request.excludedEpisodeIds.sorted().joinToString(",").hashCode())
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomeCandidatesRequestBuilder.kt`
around lines 103 - 125, Update HomeCandidatesRequestBuilder.cacheKey to include
request.daypart and request.noveltyPreference, and encode the complete
request.excludedPodcastIds collection instead of truncating it with take(40).
Preserve the existing deterministic ordering for exclusions and all other key
components.

Comment on lines +70 to +127
suspend fun loadSlate(request: SlateRequest): SlateResult {
val meaningfulPlays = HomeMeaningfulPlayLogic.countMeaningfulPlays(request.history)
val hasEligibleAnchor = !request.anchorPodcastId.isNullOrBlank()
val modeBeforeLoad =
HomePersonalizationModeLogic.derive(
meaningfulPlayCount = meaningfulPlays,
hasEligiblePositiveShow = hasEligibleAnchor,
personalizedCandidatesLoaded = false,
personalizedRequestFailed = false,
)

val modules =
when {
request.becauseYouLikeOnly -> listOf("because_you_like")
modeBeforeLoad == HomePersonalizationMode.REGIONAL -> listOf("regional")
request.modules.isNotEmpty() -> request.modules
else ->
buildList {
add("taste")
if (hasEligibleAnchor) add("because_you_like")
add("mission")
add("regional")
}
}

val body =
HomeCandidatesRequestBuilder.build(
HomeCandidatesRequestBuilder.BuildRequest(
modules = modules,
country = request.country,
languages = request.languages,
history = request.history,
anchorPodcastId = request.anchorPodcastId,
missionId = request.missionId,
excludedPodcastIds = request.excludedPodcastIds,
excludedEpisodeIds = request.excludedEpisodeIds,
noveltyPreference = request.noveltyPreference,
daypart = request.daypart,
revision = request.revision,
),
)
val fetchStartedAt = System.currentTimeMillis()
val fetch = podcastRepository.getHomeCandidatesV1(body)
val fetchMeta =
HomeCandidatesFetchMeta(
fromCache = fetch.fromCache,
cacheAgeMillis = fetch.cacheAgeMillis,
responseLatencyMillis = System.currentTimeMillis() - fetchStartedAt,
requestedModules = modules,
)
return mapResponse(
response = fetch.response,
fetchMeta = fetchMeta,
request = request,
meaningfulPlays = meaningfulPlays,
hasEligibleAnchor = hasEligibleAnchor,
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Look for tests exercising HomePersonalizationCoordinator's allocation/mapping path.
rg -n "HomePersonalizationCoordinator" --type=kt -g '*Test.kt' -C2
rg -n "allocateModules|buildPersonalizedResult|loadSlate" --type=kt -g '*Test.kt' -C2

Repository: boxcreate/boxlore

Length of output: 209


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## candidate files\n'
git ls-files 'core/catalog/src/main/java/**HomePersonalizationCoordinator.kt' \
             'core/catalog/src/test/**' \
             'core/catalog/**README.md' \
             'ARCHITECTURE.md' | sed 's#^`#-` #'

printf '\n## test references\n'
rg -n --glob '*Test.kt' 'HomePersonalizationCoordinator|allocateModules|buildPersonalizedResult|loadSlate|mapResponse' core/catalog/src/test core/catalog/src/main/java || true

printf '\n## coordinator outline\n'
ast-grep outline core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt --view expanded || true

Repository: boxcreate/boxlore

Length of output: 3678


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## coordinator excerpt\n'
nl -ba core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt | sed -n '1,260p'

printf '\n## test files mentioning related logic\n'
fd -a --type f 'core/catalog/src/test' | sed 's#^`#-` #'

printf '\n## candidate test excerpts\n'
for f in \
  core/catalog/src/test/**/HomeSlateQualityLogicTest.kt \
  core/catalog/src/test/**/HomeCandidatesRequestBuilderTest.kt \
  core/catalog/src/test/**/HomePersonalizationLogicTest.kt
do
  if [ -f "$f" ]; then
    echo "----- $f"
    nl -ba "$f" | sed -n '1,260p'
  fi
done

Repository: boxcreate/boxlore

Length of output: 219


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show any direct tests or indirect call sites for the coordinator.
rg -n --glob '*Test.kt' \
  'HomePersonalizationCoordinator|HomeCandidatesRequestBuilder|HomePersonalizationLogic|HomeSlateQualityLogic|allocateModules|buildPersonalizedResult|mapResponse|loadSlate' \
  core/catalog/src/test core/catalog/src/main/java || true

# Focus on the coordinator implementation and any nearby test utilities.
nl -ba core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt | sed -n '1,220p'

Repository: boxcreate/boxlore

Length of output: 3190


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# List catalog test files to see what coverage exists around the coordinator.
find core/catalog/src/test -type f | sort

# Show module README presence required by policy.
find core/catalog -maxdepth 2 -name README.md | sort

Repository: boxcreate/boxlore

Length of output: 2657


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Narrow search with a valid file filter.
rg -n --glob '*Test.kt' \
  'HomePersonalizationCoordinator|allocateModules|buildPersonalizedResult|mapResponse|loadSlate|HomeCandidatesRequestBuilder|HomePersonalizationLogic|HomeSlateQualityLogic' \
  core/catalog/src/test core/catalog/src/main/java || true

Repository: boxcreate/boxlore

Length of output: 3149


🏁 Script executed:

#!/bin/bash
set -euo pipefail

nl -ba core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt | sed -n '1,220p'

Repository: boxcreate/boxlore

Length of output: 195


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show only test file names to decide whether there is a direct coordinator test.
find core/catalog/src/test -type f | sort | sed -n '1,200p'

Repository: boxcreate/boxlore

Length of output: 2634


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## coordinator\n'
sed -n '1,260p' core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt

printf '\n## HomePersonalizationLogicTest\n'
sed -n '1,260p' core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationLogicTest.kt

printf '\n## HomeCandidatesRequestBuilderTest\n'
sed -n '1,260p' core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/home/HomeCandidatesRequestBuilderTest.kt

printf '\n## HomeSlateQualityLogicTest\n'
sed -n '1,260p' core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/home/HomeSlateQualityLogicTest.kt

Repository: boxcreate/boxlore

Length of output: 27985


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show exact declarations in the coordinator around module allocation and response mapping.
sed -n '1,380p' core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt

Repository: boxcreate/boxlore

Length of output: 14621


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## coordinator\n'
sed -n '1,380p' core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt

printf '\n## HomePersonalizationLogicTest\n'
sed -n '1,260p' core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationLogicTest.kt

printf '\n## HomeCandidatesRequestBuilderTest\n'
sed -n '1,260p' core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/home/HomeCandidatesRequestBuilderTest.kt

printf '\n## HomeSlateQualityLogicTest\n'
sed -n '1,260p' core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/home/HomeSlateQualityLogicTest.kt

Repository: boxcreate/boxlore

Length of output: 32076


Add coordinator-level JVM coverage HomePersonalizationLogicTest, HomeCandidatesRequestBuilderTest, and HomeSlateQualityLogicTest cover the extracted helpers, but not HomePersonalizationCoordinator.loadSlate/mapResponse itself. Add a hermetic test for the module-selection + response-mapping path, including the cross-module de-dup case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/home/HomePersonalizationCoordinator.kt`
around lines 70 - 127, Add coordinator-level JVM coverage in
HomePersonalizationCoordinatorTest for loadSlate and mapResponse using a
hermetic podcastRepository stub. Verify module selection, response mapping, and
cross-module podcast de-duplication, while keeping existing helper tests
unchanged.

Source: Path instructions

Comment on lines 52 to +57
title = podcast.title.replace("+", " "),
subtitle = podcast.artist.replace("+", " "),
onClick = onClick,
modifier = modifier,
onLongClick = onFeedback?.let { { feedbackMenuExpanded = true } },
onLongClickLabel = "More options for ${podcast.title}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the long-press label consistent with the displayed title.

The visual title replaces + with spaces, but TalkBack receives the raw title.

Proposed fix
-        onLongClickLabel = "More options for ${podcast.title}",
+        onLongClickLabel = "More options for ${podcast.title.replace("+", " ")}",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
title = podcast.title.replace("+", " "),
subtitle = podcast.artist.replace("+", " "),
onClick = onClick,
modifier = modifier,
onLongClick = onFeedback?.let { { feedbackMenuExpanded = true } },
onLongClickLabel = "More options for ${podcast.title}",
title = podcast.title.replace("+", " "),
subtitle = podcast.artist.replace("+", " "),
onClick = onClick,
modifier = modifier,
onLongClick = onFeedback?.let { { feedbackMenuExpanded = true } },
onLongClickLabel = "More options for ${podcast.title.replace("+", " ")}",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/PodcastCard.kt`
around lines 52 - 57, Update the onLongClickLabel in the PodcastCard call to use
the same plus-to-space title transformation as the displayed title, so TalkBack
announces the formatted podcast title consistently.

Comment on lines 61 to 77
launch {
fastJob.join()
_isRecommendationsLoaded.value = false
_isTrendingLoaded.value = false
try {
android.util.Log.d("BoxCastTiming", "VM: Background personalized Home screen load for region=$region")

val interests = boxcastPrefs.getUserGenres().toList()

val historyDeferred = async { playbackRepository.getHistoryForRecommendations(15) }
val subscribedIdsDeferred = async { subscriptionRepository.subscribedPodcastIds.first().toList() }
val subscribedPodcastsDeferred = async { subscriptionRepository.subscribedPodcasts.first() }

val history = historyDeferred.await()
val subscribedIds = subscribedIdsDeferred.await()
val subscribedPodcasts = subscribedPodcastsDeferred.await()
val subscribedGenres = subscribedPodcasts.mapNotNull { it.genre }.distinct()

val bootstrapData =
podcastRepository.getHomeBootstrapData(
podcastRepository.getHomeBootstrapDataFast(
country = region,
vibeIds = emptyList(),
history = history,
interests = interests,
subscribedPodcastIds = subscribedIds,
subscribedGenres = subscribedGenres,
)

val distinctRecs =
bootstrapData.recommendations
.distinctBy { it.id }
.distinctBy { it.title.lowercase().trim() }
_recommendations.value = distinctRecs
_isRecommendationsFallback.value = bootstrapData.isRecommendationsFallback
try {
val json = Json { ignoreUnknownKeys = true }
val serialized = json.encodeToString(distinctRecs)
boxcastPrefs.saveRecommendationsCache(
serialized,
bootstrapData.isRecommendationsFallback,
)
} catch (ce: Exception) {
android.util.Log.e("HomeViewModel", "Failed to cache recommendations", ce)
}
_briefingState.value = bootstrapData.briefing
_briefingChaptersState.value = bootstrapData.briefingChapters
trendingState.value = bootstrapData.trending
} catch (e: Exception) {
android.util.Log.e("BoxCastTiming", "VM: Recommendations load failed", e)
android.util.Log.e("BoxCastTiming", "VM: Fast Bootstrap API load failed", e)
} finally {
_isRecommendationsLoaded.value = true
_isTrendingLoaded.value = true
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the reported lines.
FILE="feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt"
echo "== $FILE =="
nl -ba "$FILE" | sed -n '1,220p'

echo
echo "== Nearby tests in feature/home =="
git ls-files 'feature/home/src/test/**/*.kt' 'feature/home/src/androidTest/**/*.kt' | sed 's#^`#-` #'

echo
echo "== Search for CancellationException handling patterns =="
rg -n "CancellationException|catch \(e: Exception\)|catch \(.*Exception" feature/home core app -g '*.kt'

Repository: boxcreate/boxlore

Length of output: 283


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the file in full context around the reactive wiring and state updates.
FILE="feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt"
wc -l "$FILE"
sed -n '1,260p' "$FILE"

Repository: boxcreate/boxlore

Length of output: 14381


Rethrow CancellationException here.
catch (e: Exception) also traps coroutine cancellation, so a cancelled viewModelScope job will be logged as a bootstrap failure and still reach finally. Catch CancellationException separately and rethrow it before handling other exceptions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt`
around lines 61 - 77, Update the exception handling in the coroutine launched by
the home data-loading flow to catch CancellationException before the general
Exception handler and immediately rethrow it. Keep logging non-cancellation
bootstrap failures through the existing BoxCastTiming Log.e call, while
preserving the existing state updates and cleanup behavior for handled
exceptions.

Comment on lines +220 to +262
private fun HomeViewModel.recordSlateExposures(
taste: List<Episode>,
becauseYouLike: List<Episode>,
mission: List<Episode>,
) {
viewModelScope.launch(Dispatchers.IO) {
recordRailExposures(taste, exposureEntryPointBySurface.getValue("taste"))
recordRailExposures(becauseYouLike, exposureEntryPointBySurface.getValue("because_you_like"))
recordRailExposures(mission, exposureEntryPointBySurface.getValue("mission"))
}
}

private suspend fun HomeViewModel.recordRailExposures(
episodes: List<Episode>,
entryPoint: String,
) {
episodes.forEach { episode ->
val exposureId =
rankingFeedback.recordExposure(
RankingExposure(
episodeId = episode.id,
podcastId = episode.podcastId.orEmpty(),
objective = RankingObjective.SLATE,
surface = RankingSurface.HOME,
source = CandidateSource.SERVER_RECOMMENDATION,
features =
CandidateFeatureBuilder.build(
CandidateSignals(
serverRelevance = (episode.retrievalScore ?: 0.0).coerceIn(0.0, 1.0),
isUnseenShow = true,
isUnplayed = true,
),
),
entryPoint = entryPoint,
online = true,
retrievalReason = episode.recommendationReason,
),
)
if (exposureId.isNotBlank()) {
episodeExposureIds[episode.id] = exposureId
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

episodeExposureIds grows unbounded across slate reloads.

recordRailExposures only inserts into episodeExposureIds; entries for episodes that fall out of the slate on the next reload (region/daypart/subscription changes, feedback-triggered reallocation, mission rotation) are never evicted. Over a long session this map keeps accumulating stale episode→exposure mappings for the life of the HomeViewModel.

Since the slate arrays are fully replaced on every reload anyway, clearing the map before repopulating (or at the top of recordSlateExposures) is safe and bounds it to the current slate's size.

🧹 Proposed fix
 private fun HomeViewModel.recordSlateExposures(
     taste: List<Episode>,
     becauseYouLike: List<Episode>,
     mission: List<Episode>,
 ) {
     viewModelScope.launch(Dispatchers.IO) {
+        episodeExposureIds.clear()
         recordRailExposures(taste, exposureEntryPointBySurface.getValue("taste"))
         recordRailExposures(becauseYouLike, exposureEntryPointBySurface.getValue("because_you_like"))
         recordRailExposures(mission, exposureEntryPointBySurface.getValue("mission"))
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun HomeViewModel.recordSlateExposures(
taste: List<Episode>,
becauseYouLike: List<Episode>,
mission: List<Episode>,
) {
viewModelScope.launch(Dispatchers.IO) {
recordRailExposures(taste, exposureEntryPointBySurface.getValue("taste"))
recordRailExposures(becauseYouLike, exposureEntryPointBySurface.getValue("because_you_like"))
recordRailExposures(mission, exposureEntryPointBySurface.getValue("mission"))
}
}
private suspend fun HomeViewModel.recordRailExposures(
episodes: List<Episode>,
entryPoint: String,
) {
episodes.forEach { episode ->
val exposureId =
rankingFeedback.recordExposure(
RankingExposure(
episodeId = episode.id,
podcastId = episode.podcastId.orEmpty(),
objective = RankingObjective.SLATE,
surface = RankingSurface.HOME,
source = CandidateSource.SERVER_RECOMMENDATION,
features =
CandidateFeatureBuilder.build(
CandidateSignals(
serverRelevance = (episode.retrievalScore ?: 0.0).coerceIn(0.0, 1.0),
isUnseenShow = true,
isUnplayed = true,
),
),
entryPoint = entryPoint,
online = true,
retrievalReason = episode.recommendationReason,
),
)
if (exposureId.isNotBlank()) {
episodeExposureIds[episode.id] = exposureId
}
}
}
private fun HomeViewModel.recordSlateExposures(
taste: List<Episode>,
becauseYouLike: List<Episode>,
mission: List<Episode>,
) {
viewModelScope.launch(Dispatchers.IO) {
episodeExposureIds.clear()
recordRailExposures(taste, exposureEntryPointBySurface.getValue("taste"))
recordRailExposures(becauseYouLike, exposureEntryPointBySurface.getValue("because_you_like"))
recordRailExposures(mission, exposureEntryPointBySurface.getValue("mission"))
}
}
private suspend fun HomeViewModel.recordRailExposures(
episodes: List<Episode>,
entryPoint: String,
) {
episodes.forEach { episode ->
val exposureId =
rankingFeedback.recordExposure(
RankingExposure(
episodeId = episode.id,
podcastId = episode.podcastId.orEmpty(),
objective = RankingObjective.SLATE,
surface = RankingSurface.HOME,
source = CandidateSource.SERVER_RECOMMENDATION,
features =
CandidateFeatureBuilder.build(
CandidateSignals(
serverRelevance = (episode.retrievalScore ?: 0.0).coerceIn(0.0, 1.0),
isUnseenShow = true,
isUnplayed = true,
),
),
entryPoint = entryPoint,
online = true,
retrievalReason = episode.recommendationReason,
),
)
if (exposureId.isNotBlank()) {
episodeExposureIds[episode.id] = exposureId
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSlate.kt`
around lines 220 - 262, Clear episodeExposureIds at the start of
recordSlateExposures before launching the exposure-recording work, then
repopulate it from the current taste, becauseYouLike, and mission lists.
Preserve the existing recordRailExposures behavior and ensure stale mappings
from prior slate reloads are removed.

Comment on lines +359 to +376
/**
* Builds the playback `sourceContext` extras carrying the Home slate's exact exposure token.
* [EXPOSURE_ID_KEY] must match `PlaybackTelemetrySession.EXPOSURE_ID_KEY` (internal to
* `:core:playback`, so feature modules mirror the plain string key like `"entry_point"` above).
*/
internal fun HomeViewModel.buildExposureSourceContext(
episodeId: String,
entryPoint: cx.aswin.boxlore.core.model.PlaybackEntryPoint,
): android.os.Bundle? {
val exposureId = episodeExposureIds[episodeId] ?: return null
return android.os.Bundle().apply {
putString("entry_point", entryPoint.name.lowercase())
putString(EXPOSURE_ID_KEY, exposureId)
}
}

private const val EXPOSURE_ID_KEY = "exposure_id"
private const val HOME_SLATE_HISTORY_LIMIT = 15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

EXPOSURE_ID_KEY is a duplicated magic string across module boundaries — see consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelSlate.kt`
around lines 359 - 376, Replace the locally duplicated EXPOSURE_ID_KEY string in
HomeViewModel.buildExposureSourceContext with the shared playback telemetry
exposure-key symbol or centralized contract, updating the Bundle write
accordingly while preserving the existing exposure token value and entry-point
behavior.

Comment on lines +117 to +120
const oldestDeferredAgeHours = deferredWithHistory.length > 0
? Math.round((now - Math.min(...deferredWithHistory.map((id) => st.shows[id].c))) / 3600000)
: 0;
const runsToClearBacklog = deferred > 0 ? Math.ceil(deferred / cfg.MAX_CHECKS_PER_RUN) : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep unknown backlog age distinct from zero.

When every deferred show is never checked, oldestDeferredAgeHours becomes 0; Lines 130, 218, 223, and 230 then report a real-looking 0h age despite there being no prior-check timestamp. Use a nullable/n/a age and phrase these details as unavailable when no deferred show has history.

Suggested fix
-    const oldestDeferredAgeHours = deferredWithHistory.length > 0
+    const oldestDeferredAgeHours = deferredWithHistory.length > 0
         ? Math.round((now - Math.min(...deferredWithHistory.map((id) => st.shows[id].c))) / 3600000)
-        : 0;
+        : null;

+    const backlogAgeDetail = oldestDeferredAgeHours === null
+        ? 'age unavailable: no deferred show has a prior check'
+        : `oldest previously checked show waiting ${log.fmt(oldestDeferredAgeHours)}h`;

Also applies to: 130-130, 218-218, 223-223, 230-230

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/sync/03-sync-episodes.js` around lines 117 - 120, Update
oldestDeferredAgeHours and the related backlog messages at the identified
reporting points to preserve an unknown age when deferredWithHistory is empty,
using a nullable or n/a representation instead of 0. Ensure messages in the
deferred backlog reporting flow phrase the age as unavailable when no deferred
show has a prior-check timestamp, while retaining numeric hour output when
history exists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend-change Touches backend/proxy/infra — pairable with any user-impact level user-impact-high Listeners clearly notice this change — prioritize README and notification

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant