feat(vibe): plan musical arcs and callbacks
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
|
||||
import { SessionDirector } from './session-director.service.js';
|
||||
import { Candidate } from './generators.service.js';
|
||||
|
||||
/**
|
||||
* This is deliberately a narrow bridge between the durable Vibe ledger and
|
||||
@@ -67,7 +68,7 @@ export interface VibeSessionResponse {
|
||||
export class VibeSessionCoordinator {
|
||||
constructor(
|
||||
private readonly db: DbService,
|
||||
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState'>,
|
||||
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState' | 'replan'>,
|
||||
) {}
|
||||
|
||||
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
|
||||
@@ -111,15 +112,18 @@ export class VibeSessionCoordinator {
|
||||
policyVersion,
|
||||
intent: input.intent ?? null,
|
||||
horizonTracks: candidates.length,
|
||||
...(candidates[0]?.plan?.objective ?? {}),
|
||||
},
|
||||
items: candidates.map((candidate, ordinal) => ({
|
||||
ordinal,
|
||||
track_id: candidate.trackId,
|
||||
slot_role: ordinal === 0 ? 'next' : null,
|
||||
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
|
||||
candidate_source: candidate.generatorId,
|
||||
score: candidate.relevance,
|
||||
score_breakdown: { relevance: candidate.relevance },
|
||||
explanation: candidate.explanation,
|
||||
score: candidate.plan?.score ?? candidate.relevance,
|
||||
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
|
||||
explanation: candidate.plan
|
||||
? { paths: candidate.explanation, planner: candidate.plan.explanation }
|
||||
: candidate.explanation,
|
||||
committed: false,
|
||||
})),
|
||||
});
|
||||
@@ -216,11 +220,24 @@ export class VibeSessionCoordinator {
|
||||
// feedback tracks, the seed is not necessarily present in the event
|
||||
// ledger, so carry it explicitly into every replacement request.
|
||||
const seedTrackId = session.seed_track_id ?? undefined;
|
||||
const candidates = seedTrackId
|
||||
? await this.director.buildPlan(userId, sessionId, input.trackId ?? seedTrackId, {
|
||||
excludedTrackIds: new Set([seedTrackId]),
|
||||
})
|
||||
: await this.director.buildPlan(userId, sessionId, input.trackId);
|
||||
// Revisions retain the durable, unserved queue tail rather than building
|
||||
// an unrelated plan after every signal. Besides reducing churn, this
|
||||
// preserves a valid callback/recovery pair that has already been shown
|
||||
// to the client while allowing the director to refill under the same
|
||||
// hard caps and current feedback state.
|
||||
const current = await this.db.getVibePlan(sessionId, userId);
|
||||
const retained = current ? this.unservedCandidates(current) : [];
|
||||
const excludedTrackIds = new Set<string>([
|
||||
...(seedTrackId ? [seedTrackId] : []),
|
||||
]);
|
||||
const candidates = await this.director.replan(
|
||||
userId,
|
||||
sessionId,
|
||||
retained,
|
||||
input.trackId ? [input.trackId] : [],
|
||||
input.trackId ?? seedTrackId,
|
||||
{ excludedTrackIds },
|
||||
);
|
||||
const reason = `feedback:${input.type}`;
|
||||
const plan = await this.db.publishVibePlan({
|
||||
sessionId,
|
||||
@@ -232,15 +249,18 @@ export class VibeSessionCoordinator {
|
||||
feedbackEventId: result.event.id,
|
||||
feedbackType: input.type,
|
||||
horizonTracks: candidates.length,
|
||||
...(candidates[0]?.plan?.objective ?? {}),
|
||||
},
|
||||
items: candidates.map((candidate, ordinal) => ({
|
||||
ordinal,
|
||||
track_id: candidate.trackId,
|
||||
slot_role: ordinal === 0 ? 'next' : null,
|
||||
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
|
||||
candidate_source: candidate.generatorId,
|
||||
score: candidate.relevance,
|
||||
score_breakdown: { relevance: candidate.relevance },
|
||||
explanation: candidate.explanation,
|
||||
score: candidate.plan?.score ?? candidate.relevance,
|
||||
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
|
||||
explanation: candidate.plan
|
||||
? { paths: candidate.explanation, planner: candidate.plan.explanation }
|
||||
: candidate.explanation,
|
||||
committed: false,
|
||||
})),
|
||||
});
|
||||
@@ -303,6 +323,37 @@ export class VibeSessionCoordinator {
|
||||
};
|
||||
}
|
||||
|
||||
/** Reconstruct the planner envelope from the durable revision. Older
|
||||
* revisions stored only paths, so they remain valid retention inputs. */
|
||||
private unservedCandidates(plan: VibePlan): Candidate[] {
|
||||
return plan.items
|
||||
.filter(item => !item.committed)
|
||||
.map(item => {
|
||||
const stored = item.explanation;
|
||||
const hasPlanner = !!stored && !Array.isArray(stored) && typeof stored === 'object'
|
||||
&& 'planner' in stored;
|
||||
const object = hasPlanner ? stored as { paths?: Candidate['explanation']; planner?: Record<string, unknown> } : undefined;
|
||||
const planner = object?.planner;
|
||||
return {
|
||||
trackId: item.track_id,
|
||||
generatorId: item.candidate_source,
|
||||
relevance: item.score,
|
||||
explanation: object?.paths ?? (Array.isArray(stored) ? stored : []),
|
||||
plan: planner ? {
|
||||
slotRole: item.slot_role ?? 'retained',
|
||||
score: item.score,
|
||||
scoreBreakdown: item.score_breakdown,
|
||||
explanation: planner,
|
||||
objective: {
|
||||
policy: planner.policy,
|
||||
constraints: planner.constraints,
|
||||
relaxations: planner.relaxations,
|
||||
},
|
||||
} : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private mapLifecycleError(error: unknown): Error {
|
||||
if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) {
|
||||
return new VibeSessionLifecycleError(error.message);
|
||||
|
||||
Reference in New Issue
Block a user