Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions .claude/rules/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,8 @@ keep it lean (ceiling ~200 lines). The *why* behind a non-obvious choice belongs
in `docs/adr/` — and the docstring that implements it cites the ADR, so an agent
meets the rationale before it "simplifies" the choice away.

`docs/adr/` doesn't exist yet; it lands via a `grill-with-docs` pass that will
promote today's scattered rationale (HNSW-over-IVFFlat in the pgvector
migration, iTunes-over-`preview_url` in `services/audio_catalog.py`, the
two-layer tool-error convention, label binarization at the subject median) into
numbered records. Until then, cite the existing home and keep new rationale out
of `AGENTS.md`.
`docs/adr/` now holds four numbered records — HNSW-over-IVFFlat (0001),
iTunes-over-`preview_url` (0002), the two-layer tool-error convention (0003),
and subject-median label binarization (0004). A new non-obvious choice gets a
new ADR (next number, format per `.agents/skills/domain-modeling/ADR-FORMAT.md`),
and the docstring implementing it cites the file by path.
18 changes: 18 additions & 0 deletions .claude/rules/frontend/code-conventions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
paths:
- "frontend/components/**/*.{ts,tsx}"
- "frontend/features/**/*.{ts,tsx}"
- "frontend/app/**/*.{ts,tsx}"
- "frontend/hooks/**/*.{ts,tsx}"
- "frontend/api/hooks/**/*.{ts,tsx}"
Expand All @@ -11,6 +12,23 @@ paths:

TypeScript/Next.js conventions for the cortexdj frontend.

## Feature slices

Domain UI lives in `frontend/features/<name>/` (kebab-case files, same as
everywhere else) — `sessions/` and `retrieval/` today. What goes where:

- **A slice never imports another slice.** `frontend/features/tool-panel-registry.ts`
is the composition root and the only file that reaches across slices; it
spreads each slice's `tool-panels.tsx` map into `TOOL_PANELS`.
- **Shared chat chrome stays in `components/`** (message renderer, input,
sidebar, `ui/`). A component used by two slices belongs there, not in either
slice.
- **Cross-cutting data hooks stay in `api/hooks/`**, shared parsing/format
helpers in `lib/`. A slice may own a hook only it uses.

Intra-slice imports are relative (`./tool-panels`); anything crossing a
top-level directory uses `@/`.

## Imports

- Use the `@/` path alias for imports that cross top-level directories
Expand Down
11 changes: 8 additions & 3 deletions .claude/rules/frontend/vercel-ai-sdk.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
paths:
- "frontend/components/**/*.tsx"
- "frontend/features/**/*.{ts,tsx}"
- "frontend/app/(chat)/**/*.{ts,tsx}"
- "frontend/hooks/**/*.{ts,tsx}"
- "frontend/api/hooks/**/*.{ts,tsx}"
Expand Down Expand Up @@ -44,6 +45,10 @@ authoritative for the underlying contract that adapted UI must wire into.
`ChatMessage` type in `frontend/lib/types.ts`. Carry that generic through
every `UseChatHelpers` site — falling back to plain `UIMessage` loses the
project's custom data/tool-part typing.
- **Tool-call panels switch on `part.type === "tool-<name>"`** in the message
renderer. A new backend tool that needs a custom UI panel adds a branch
there.
- **Tool-call panels come from a registry, not a branch.** Each feature slice
exports a tool-name → panel map from `frontend/features/<name>/tool-panels.tsx`;
`frontend/features/tool-panel-registry.ts` spreads those maps into
`TOOL_PANELS`, and `message.tsx` looks up `part.type === "tool-<name>"` against
it. A new backend tool that needs a custom UI panel = a slice component plus
one map entry — add `hideRawOutput: true` when the panel replaces `<ToolCall>`'s
raw JSON output block. Don't reintroduce per-tool branching in the renderer.
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ FastAPI Python backend using async patterns throughout.
- **Contrastive EEG↔CLAP**: `contrastive.py` (EegCLAPEncoder with CBraMod backbone + SimCLR projection head, symmetric soft-target InfoNCE, encode_session), `contrastive_dataset.py` (DeapClapPairDataset + host-portable audio cache + `trial_to_eeg_windows` shared slicer), `contrastive_train.py` (SequentialLR warmup+cosine, TensorBoard scalars + val embedding projector, grad accumulation).
- **`src/cortexdj/core/config.py`**: Settings via pydantic-settings
- **`src/cortexdj/migrations/`**: Alembic migrations for PostgreSQL
- **`tests/`**: Three tiers — `unit/` (default run), `integration/` (real Postgres, `-m integration`), `evals/` (real LLM, `-m eval`). See `DEVELOPMENT.md` and `.claude/rules/backend/code-conventions.md`.

### Frontend (`frontend/`)

Expand All @@ -77,9 +78,10 @@ Next.js 16 with App Router.
- **`app/(chat)/api/chat/route.ts`**: Proxy route to backend agent
- **`components/chat.tsx`**: Chat orchestrator using `@ai-sdk/react` useChat hook
- **`components/brain-context-badge.tsx`**: Displays active brain context (mood/arousal/valence)
- **`components/session-visualization.tsx`**: Tabbed session viewer — wraps `components/emotion-trajectory.tsx` (default, animated SVG trajectory through Russell's affect space) and a recharts arousal/valence timeline in Radix Tabs, with the band-power chart shared below. Auto-rendered by `components/message.tsx` when an `analyze_session` tool call is detected
- **`components/emotion-trajectory.tsx`**: Custom SVG + `motion/react` chart that plots each 4-second segment as a point in the valence/arousal plane, draws a smoothed rolling-mean path via an animated `motion.path` (`style={{ pathLength: progress }}`), and exposes a play/pause + scrubber driven by a `requestAnimationFrame` loop
- **`components/retrieved-tracks-panel.tsx`**: Ranked tracks rendered beneath `retrieve_tracks_from_brain_state` tool calls — similarity bars, inline 30s preview playback via a shared `<audio>` ref, Spotify deep-links, branched 404/503/500 error states
- **`features/tool-panel-registry.ts`**: Composition root mapping tool name → panel; the only file that imports across slices. `components/message.tsx` looks up `TOOL_PANELS` — a new tool panel is a slice component plus one map entry, never a renderer branch. See `.claude/rules/frontend/code-conventions.md` for the slice boundaries.
- **`features/sessions/session-visualization.tsx`**: Tabbed session viewer — wraps `features/sessions/emotion-trajectory.tsx` (default, animated SVG trajectory through Russell's affect space) and a recharts arousal/valence timeline in Radix Tabs, with the band-power chart shared below. Registered for `analyze_session`
- **`features/sessions/emotion-trajectory.tsx`**: Custom SVG + `motion/react` chart that plots each 4-second segment as a point in the valence/arousal plane, draws a smoothed rolling-mean path via an animated `motion.path` (`style={{ pathLength: progress }}`), and exposes a play/pause + scrubber driven by a `requestAnimationFrame` loop
- **`features/retrieval/retrieved-tracks-panel.tsx`**: Ranked tracks rendered beneath `retrieve_tracks_from_brain_state` tool calls — similarity bars, inline 30s preview playback via a shared `<audio>` ref, Spotify deep-links, branched 404/503/500 error states
- **`api/hooks/sessions.ts`**: TanStack Query wrappers around the generated sessions client — `useSessionSegments` and `useSimilarTracks`; follow this pattern when wrapping new generated endpoints. Retries skip 404 (missing session) and 503 (missing contrastive checkpoint)

### Data Flow
Expand All @@ -90,11 +92,12 @@ Next.js 16 with App Router.
4. The `ProcessHistory` capability (`summarize_tool_results`) compacts large tool results from prior turns to prevent token bloat
5. Pydantic AI agent decides which tools to call
6. Agent streams response back as SSE (Vercel AI SDK format)
7. Frontend renders with tool-call transparency, brain context badge, and inline panels: `<SessionVisualization>` on `analyze_session`, `<RetrievedTracksPanel>` on `retrieve_tracks_from_brain_state` (component-level details in the Frontend section above)
7. Frontend renders with tool-call transparency, brain context badge, and inline panels — `message.tsx` resolves each tool part against `features/tool-panel-registry.ts`: `<SessionVisualization>` on `analyze_session`, `<RetrievedTracksPanel>` on `retrieve_tracks_from_brain_state` (component-level details in the Frontend section above)

## Additional Instructions

- Backend port: 8003, Frontend port: 3003, PostgreSQL port: 5433
- Why is this code like this? Check `docs/adr/` before "fixing" a non-obvious choice — it records the decisions that look wrong without context, and the implementing docstring cites its ADR.
- Model checkpoints are gitignored -- use `uv run train-model` to train.
- After modifying backend API endpoints, regenerate the frontend client with `pnpm -C frontend generate-client`.
- Do not manually edit files in `frontend/api/generated/`.
Expand Down
4 changes: 4 additions & 0 deletions backend/src/cortexdj/agents/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
stream mid-response. ``on_tool_execute_error`` intercepts those, logs the
traceback, and returns a structured recovery payload so the agent can
explain the failure to the user conversationally.

This is layer 2 of the two-layer tool-error convention; see
docs/adr/0003-two-layer-tool-error-convention.md for why tools propagate
rather than wrap, and for the one sanctioned inline catch.
"""

import logging
Expand Down
4 changes: 4 additions & 0 deletions backend/src/cortexdj/agents/tools/retrieval_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ async def retrieve_tracks_from_brain_state(
`note` field explains how to populate it. When the session's underlying
DEAP data is missing on disk, the payload contains an `error` field
that the agent should relay verbatim to the user.

The `DeapFileMissingError` catch below is the one sanctioned deviation from
the propagate-to-hooks convention; see
docs/adr/0003-two-layer-tool-error-convention.md. Do not add a second one.
"""
try:
hits = await retrieval_service.retrieve_similar_tracks(ctx.deps.db, session_id, k=k)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def upgrade() -> None:
# pass; HNSW builds on empty and updates the graph on insert, with better
# recall at our 2k–10k row scale. m=16, ef_construction=64 are pgvector
# defaults — no tuning needed until the table grows past ~100k rows.
# See docs/adr/0001-hnsw-over-ivfflat.md.
op.execute("CREATE EXTENSION IF NOT EXISTS vector")

op.create_table(
Expand Down
2 changes: 2 additions & 0 deletions backend/src/cortexdj/ml/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
CBRAMOD_SCALE_FACTOR = 0.01

# Label binarization strategy for DEAP's 1-9 Likert self-reports.
# See docs/adr/0004-subject-median-label-binarization.md for why the default
# deviates from the >= 5 threshold the DEAP literature uses.
#
# `median_per_subject` (default): splits each axis at that subject's own
# median. Balanced within each subject, removes per-subject rating-scale bias.
Expand Down
7 changes: 7 additions & 0 deletions backend/src/cortexdj/models/track_audio_embedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@


class TrackAudioEmbedding(Base):
"""CLAP audio embeddings for tracks, searched by cosine distance.

`get_top_k_similar`'s ordering is served by an HNSW index (built in
migration 77c744e4b096) — keep the order-by on `cosine_distance` so the
index applies; see docs/adr/0001-hnsw-over-ivfflat.md.
"""

__tablename__ = "track_audio_embeddings"

id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
Expand Down
2 changes: 2 additions & 0 deletions backend/src/cortexdj/services/audio_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
2024-11-27 — empirically verified 0/10 hits against this project's 2018
Spotify app. iTunes Search API is the audio-bytes source; Spotify stays
as the source of truth for track identity.

See docs/adr/0002-itunes-previews-over-spotify-preview-url.md.
"""

import asyncio
Expand Down
15 changes: 15 additions & 0 deletions docs/adr/0001-hnsw-over-ivfflat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# HNSW over IVFFlat for the track-embedding index

**Status**: Accepted — 2026-07-16

The `track_audio_embeddings` cosine-similarity index (pgvector) is created by
migration `77c744e4b096` on a table that is empty at migration time and filled
later by incremental seed passes. IVFFlat needs a training step over existing
rows to build its lists, so an IVFFlat index created here would be trained on
nothing and degrade to a sequential scan until a manual `REINDEX` after every
seed pass. We use HNSW instead: it builds on an empty table and updates its
graph on insert, and gives better recall at this project's ~2k–10k row scale.

`m = 16, ef_construction = 64` are pgvector's defaults and are left untuned;
revisit only if the table grows past ~100k rows, where IVFFlat's smaller build
cost and memory footprint start to matter.
16 changes: 16 additions & 0 deletions docs/adr/0002-itunes-previews-over-spotify-preview-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# iTunes Search previews instead of Spotify `preview_url`

**Status**: Accepted — 2026-07-16

Spotify deprecated `preview_url` for standard-mode apps on 2024-11-27; against
this project's Spotify app it now returns 0/10 hits, so it cannot supply the 30s
audio the CLAP encoder needs. `services/audio_catalog.py` resolves previews from
the iTunes Search API instead, and Spotify remains the source of truth for track
identity — an iTunes hit is only accepted for a track Spotify already named.

Because iTunes matches on a text query, a hit can be the wrong edit of the right
song. Spotify's `duration_ms` anchors the match: candidates outside a 3s
duration delta are rejected outright, and survivors are ranked by artist
similarity, then title similarity, then smallest delta. Do not drop the duration
filter in favor of text similarity alone — remasters and live versions score
identically on title.
20 changes: 20 additions & 0 deletions docs/adr/0003-two-layer-tool-error-convention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Two-layer error handling for agent tools

**Status**: Accepted — 2026-07-16

An exception escaping a Pydantic AI tool body would crash the Vercel AI SDK SSE
stream mid-response. Rather than wrap every tool in try/except — which spreads
error presentation across the tool layer and buries real bugs — tools let
unanticipated exceptions propagate to `agents/hooks.py`, whose
`tool_execute_error` hook logs the traceback and returns a structured recovery
payload naming the tool and exception class, so the agent apologizes
conversationally instead of dying. Anticipated failures (Spotify not configured,
token expired) still return `{"error": ...}` dicts from the tool body itself.

The one sanctioned exception is `retrieval_tools.retrieve_tracks_from_brain_state`,
which catches `DeapFileMissingError` inline. The hook's payload deliberately
carries only the exception's class name, not its message — but this error's
message is the actionable part (which DEAP file is missing, and where to put
it). Catching it at the tool lets that text reach the user verbatim. Adding a
second inline catch is a signal to re-examine this ADR, not to follow the
precedent.
19 changes: 19 additions & 0 deletions docs/adr/0004-subject-median-label-binarization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Binarize DEAP labels at each subject's own median

**Status**: Accepted — 2026-07-16

DEAP's valence/arousal labels are 1–9 Likert self-reports. The convention in the
DEAP literature is to threshold at `>= 5`, which here produces a ~25/75 class
skew and bakes in per-subject rating-scale bias — some participants never use
the low end of the scale, so the "low arousal" class is partly a fact about the
rater, not the trial. Under our leave-one-subject-out CV regime that bias lands
squarely in the held-out fold.

`ml/dataset.py` therefore defaults to `median_per_subject`: each axis is split
at that subject's own median across their 40 trials, giving roughly balanced
labels per subject and removing the scale bias. `median_global` (pooled median)
and `fixed_5` remain opt-in via `--label-split`; `fixed_5` exists to reproduce
published numbers and should not be read as our baseline.

**Consequence**: accuracy figures here are not directly comparable to papers
using the `>= 5` split — a `fixed_5` run is the apples-to-apples comparison.
Loading