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
88 changes: 87 additions & 1 deletion apps/ui/src/components/AnalysisResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ import { ConfidenceBandBadge } from './sessionMusician/ConfidenceBandBadge';
import { RecommendationVerificationBadge } from './RecommendationVerificationBadge';
import { toConfidenceBand } from '../services/sessionMusician/confidenceBand';
import { loadAppliedIds, toggleAppliedId } from '../services/appliedRecommendations';
import {
formatContractRange,
formatContractValue,
} from '../services/recommendationsContract';
import type { RecommendationContractEntry } from '../types';
import {
buildArrangementViewModel,
buildMixChainGroups,
Expand Down Expand Up @@ -238,6 +243,64 @@ function AppliedCheckbox({
);
}

/**
* "Validated" chip for cards backed by the recommendations.v1 contract
* (ADR 0003): the backend admitted this card to the schema-validated,
* citation-gated envelope. Cards without it are exactly the ones the
* projection refused (typically: no Phase 1 citation), so the badge is the
* citation gate made visible. Renders nothing when no entries match.
*/
function ContractValidatedBadge({
entries,
testId,
}: {
entries: RecommendationContractEntry[];
testId: string;
}) {
if (entries.length === 0) return null;
return (
<span
data-testid={testId}
title="Passed the recommendations.v1 contract: schema-validated and citing at least one Phase 1 measurement."
className="text-[9px] font-mono uppercase px-1.5 py-0.5 rounded border border-success/40 text-success whitespace-nowrap"
>
✓ Validated
</span>
);
}

/**
* Expanded-card block listing the contract's normalized view of each backing
* entry: parsed value + unit and the published working range. The range is
* net-new information (the per-unit tolerance neighborhood the contract
* derives) — the raw value string already renders in the parameter grid.
*/
function ContractEntriesBlock({
entries,
testId,
}: {
entries: RecommendationContractEntry[];
testId: string;
}) {
if (entries.length === 0) return null;
return (
<div data-testid={testId} className="border border-success/20 bg-success/5 rounded-sm px-2 py-2">
<p className="text-[10px] font-mono text-success uppercase tracking-wide">
Validated · recommendations.v1
</p>
{entries.map((entry, idx) => {
const range = formatContractRange(entry);
return (
<p key={`${entry.parameter}-${idx}`} className="text-xs font-mono text-text-secondary mt-1">
{entry.parameter}: <span className="text-text-primary font-bold">{formatContractValue(entry)}</span>
{range ? ` · working range ${range}` : ''}
</p>
);
})}
</div>
);
}

function Collapsible({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) {
return (
<div
Expand Down Expand Up @@ -745,7 +808,12 @@ export function AnalysisResults({
const confidenceBadges = toConfidenceBadges(phase2?.confidenceNotes);
const arrangement = buildArrangementViewModel(phase1, phase2?.arrangementOverview);
const sonicCards = buildSonicElementCards(phase1, phase2?.sonicElements);
const mixGroups = buildMixChainGroups(phase1, phase2?.mixAndMasterChain, phase2?.sonicElements);
const mixGroups = buildMixChainGroups(
phase1,
phase2?.mixAndMasterChain,
phase2?.sonicElements,
phase2?.recommendations,
);
// Audit Finding #14: per-section applied counts, derived from the
// appliedIds Set + the rendered card lists. Keeps the progress chip in the
// section header in sync with the per-card checkboxes without a second
Expand Down Expand Up @@ -2180,6 +2248,10 @@ export function AnalysisResults({
trackContext={card.trackContext}
category={card.category}
/>
<ContractValidatedBadge
entries={card.contractEntries}
testId={`mix-chain-contract-badge-${card.id}`}
/>
</div>
{/* Audit Finding #3: primary citation visible in
the collapsed header so the chain-of-custody
Expand Down Expand Up @@ -2253,6 +2325,11 @@ export function AnalysisResults({
))}
</div>

<ContractEntriesBlock
entries={card.contractEntries}
testId={`mix-chain-contract-${card.id}`}
/>

<div className="border border-accent/20 bg-accent/5 rounded-sm px-2 py-2">
<p className="text-[10px] font-mono text-accent uppercase tracking-wide">PRO TIP</p>
<p className="text-xs font-mono text-text-secondary mt-1 leading-relaxed">
Expand Down Expand Up @@ -2353,6 +2430,10 @@ export function AnalysisResults({
trackContext={patch.trackContext}
category={patch.category}
/>
<ContractValidatedBadge
entries={patch.contractEntries}
testId={`patch-contract-badge-${patch.id}`}
/>
</div>
{/* Audit Finding #3: primary citation in the
collapsed header so the chain-of-custody
Expand Down Expand Up @@ -2423,6 +2504,11 @@ export function AnalysisResults({
))}
</div>

<ContractEntriesBlock
entries={patch.contractEntries}
testId={`patch-contract-${patch.id}`}
/>

<div className="border border-accent/20 bg-accent/5 rounded-sm px-2 py-2">
<p className="text-[10px] font-mono text-accent uppercase tracking-wide">PRO TIP</p>
<p className="text-xs font-mono text-text-secondary mt-1 leading-relaxed">
Expand Down
42 changes: 41 additions & 1 deletion apps/ui/src/components/analysisResultsViewModel.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { AbletonRecommendation, Phase1Result, Phase2Result } from "../types";
import {
AbletonRecommendation,
Phase1Result,
Phase2Result,
RecommendationContractEntry,
RecommendationsContract,
} from "../types";
import {
type ConfidenceBand,
getConfidenceBand,
toConfidenceBand,
} from "../services/sessionMusician/confidenceBand";
import {
buildRecommendationsContractIndex,
findContractEntries,
type RecommendationsContractIndex,
} from "../services/recommendationsContract";

export interface ConfidenceBadgeViewModel {
label: string;
Expand Down Expand Up @@ -144,6 +155,15 @@ export interface MixChainCardViewModel {
* don't render an empty block.
*/
phase1Fields: string[];
/**
* recommendations.v1 (ADR 0003) entries backing this card — the
* schema-validated, citation-gated projection of the same raw device card.
* Empty when the card was not admitted to the contract (no Phase 1
* citation, no usable value) or when the envelope is absent (older stored
* results, stem_summary). Synthetic frontend-derived cards are always
* empty: they never passed through the backend projection.
*/
contractEntries: RecommendationContractEntry[];
}

export interface MixChainGroupViewModel {
Expand Down Expand Up @@ -173,6 +193,8 @@ export interface PatchCardViewModel {
* across the items that group into this single patch card. Deduplicated.
*/
phase1Fields: string[];
/** See MixChainCardViewModel.contractEntries — merged across the grouped items. */
contractEntries: RecommendationContractEntry[];
}

/**
Expand Down Expand Up @@ -925,6 +947,8 @@ function makeLimiterFallbackCard(phase1: Phase1Result, nextOrder: number) {
// it consumes to derive its parameters, so the CitationBlock shows what
// makes this fallback specific to *this* track.
phase1Fields: ["truePeak", "lufsIntegrated", "stereoWidth"],
// Frontend-derived, never passed through the backend projection.
contractEntries: [],
};
}

Expand Down Expand Up @@ -990,9 +1014,11 @@ export function buildMixChainGroups(
phase1: Phase1Result,
chain: Phase2Result["mixAndMasterChain"] | undefined,
sonicElements?: Phase2Result["sonicElements"],
recommendations?: RecommendationsContract | null,
): MixChainGroupViewModel[] {
if (!Array.isArray(chain) || chain.length === 0) return [];

const contractIndex = buildRecommendationsContractIndex(recommendations);
const total = chain.length;

const cards = chain.map((item, index) => {
Expand Down Expand Up @@ -1031,6 +1057,7 @@ export function buildMixChainGroups(
// backend Phase 2 schema, enforced by server_phase2.py + phase2Validator)
// so the CitationBlock above each card has structured citations.
phase1Fields: Array.isArray(item.phase1Fields) ? [...item.phase1Fields] : [],
contractEntries: findContractEntries(contractIndex, item),
group,
highEndCues: highEnd.cues,
} satisfies MixChainCardViewModel & { group: ProcessingGroup; highEndCues: string[] };
Expand Down Expand Up @@ -1159,6 +1186,8 @@ function buildStereoWidthPatchCard(
// Audit Finding #2: synthetic stereo-width card cites the fields it
// consumes when generating its parameter recommendations.
phase1Fields: ['stereoWidth', 'stereoCorrelation', 'truePeak'],
// Frontend-derived, never passed through the backend projection.
contractEntries: [],
};
}

Expand All @@ -1173,6 +1202,7 @@ export function buildPatchCards(
const melodyInsights = buildMelodyInsights(phase1);
const recommendations = Array.isArray(phase2.abletonRecommendations) ? phase2.abletonRecommendations : [];
const grouped = groupRecommendationsByDevice(recommendations);
const contractIndex = buildRecommendationsContractIndex(phase2.recommendations);

// Audit Finding #1B (overlap): Gemini sometimes emits the same device in
// both `mixAndMasterChain` (processing) and `abletonRecommendations`
Expand Down Expand Up @@ -1241,6 +1271,13 @@ export function buildPatchCards(
),
);

// Contract entries merged across the grouped items, mirroring the
// phase1Fields merge above. Set-dedupe works on entry object identity:
// the index hands back the same envelope objects for identical cards.
const mergedContractEntries = Array.from(
new Set(group.items.flatMap((item) => findContractEntries(contractIndex, item))),
);

return {
id: `patch-${index}-${group.device}`,
device: group.device,
Expand All @@ -1256,6 +1293,7 @@ export function buildPatchCards(
),
transcriptionDerived: midiFocused,
phase1Fields: mergedPhase1Fields,
contractEntries: mergedContractEntries,
};
});

Expand Down Expand Up @@ -1288,6 +1326,8 @@ export function buildPatchCards(
melodyInsights.source === 'transcription'
? ['transcriptionDetail.averageConfidence', 'key', 'bpm']
: ['melodyDetail.pitchConfidence', 'key', 'bpm'],
// Frontend-derived, never passed through the backend projection.
contractEntries: [],
});
}

Expand Down
Loading
Loading