Files
Jyotisha/frontend/src/lib/onboarding-cache-policy.ts
T
Jesse_Chen a6d4473ef0 feat(onboarding): write a question for every consultation domain and stop generating an unread greeting
The home screen renders all ten domains from the consultation registry,
but the Agent only ever wrote three of them; the other seven were static
registry prompts dressed up as personalized starting points. The payload
now has to cover every domain in registry order, validated as a set
rather than per item, so a short or misordered answer is rejected whole
instead of silently leaving cards on static copy.

The greeting went the other way. Nothing has rendered it since the hero
note was removed, so it leaves the schema, the prompt, and the client
contract rather than costing tokens for text no one reads.

Ten questions take much longer to generate than three, so the route,
the server generation budget, and the client request deadline all grow
together, and the cache version bump forces existing payloads to be
regenerated once under the new shape.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 15:44:19 +08:00

95 lines
2.9 KiB
TypeScript

import { createHash } from "node:crypto";
// v5 drops the unrendered greeting and widens suggestions to every consultation
// domain, so every v4 payload cached in profiles must be regenerated once.
const ONBOARDING_VERSION = "ayanam-onboarding-v5";
export const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000;
type OnboardingProfileInput = {
readonly name: string | null;
readonly birthDate: string | null;
readonly birthTime: string | null;
readonly reportedBirthTime?: string | null;
readonly activeBirthTime: string | null;
readonly birthTimeSource?: string | null;
readonly birthTimePeriod?: string | null;
readonly birthTimeClue?: string | null;
readonly uncertaintyBeforeMinutes?: number | null;
readonly uncertaintyAfterMinutes?: number | null;
readonly birthTimeStatus: string | null;
readonly countryCode: string | null;
readonly provinceCode: string | null;
readonly cityCode: string | null;
};
export type OnboardingCacheIdentity = {
readonly readyVersion: string;
readonly pendingVersion: string;
};
type OnboardingCacheObservation<Payload> = {
readonly identity: OnboardingCacheIdentity;
readonly observedVersion: string | null;
readonly generatedAtMs: number;
readonly nowMs: number;
readonly cachedPayload: Payload | null;
};
type OnboardingCacheDecision<Payload> =
| { readonly kind: "ready"; readonly payload: Payload }
| { readonly kind: "pending" }
| {
readonly kind: "claim";
readonly expectedVersion: string | null;
readonly pendingVersion: string;
};
export function createOnboardingCacheIdentity(
profile: OnboardingProfileInput,
): OnboardingCacheIdentity {
const fingerprint = createHash("sha256")
.update(JSON.stringify([
profile.name,
profile.birthDate,
profile.birthTime,
profile.reportedBirthTime,
profile.activeBirthTime,
profile.birthTimeSource,
profile.birthTimePeriod,
profile.birthTimeClue,
profile.uncertaintyBeforeMinutes,
profile.uncertaintyAfterMinutes,
profile.birthTimeStatus,
profile.countryCode,
profile.provinceCode,
profile.cityCode,
]))
.digest("hex");
return {
readyVersion: `${ONBOARDING_VERSION}:${fingerprint}`,
pendingVersion: `${ONBOARDING_VERSION}:pending:${fingerprint}`,
};
}
export function decideOnboardingCache<Payload>(
observation: OnboardingCacheObservation<Payload>,
): OnboardingCacheDecision<Payload> {
if (observation.observedVersion === observation.identity.readyVersion
&& observation.cachedPayload !== null) {
return { kind: "ready", payload: observation.cachedPayload };
}
if (observation.observedVersion === observation.identity.pendingVersion
&& Number.isFinite(observation.generatedAtMs)
&& observation.nowMs - observation.generatedAtMs < ONBOARDING_CLAIM_TTL_MS) {
return { kind: "pending" };
}
return {
kind: "claim",
expectedVersion: observation.observedVersion,
pendingVersion: observation.identity.pendingVersion,
};
}