Skip to content
Draft
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: 11 additions & 0 deletions app/api/profile/generate-mystical/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { checkRateLimitWithOptionalFirestore } from '@/lib/rateLimitFirestore';
import { acquireMysticalGenerationLock, getMysticalLockRuntimeStatus } from '@/lib/generationLock';
import type { PersistedToolStatusMap } from '@/lib/mysticalStageB';
import {
clearStaleCatalogReports,
generateAndPersistToolReports,
NATAL_CHART_SLUGS,
} from '@/lib/onDemandToolReports';
Expand Down Expand Up @@ -408,6 +409,16 @@ export async function POST(request: NextRequest) {
natalTools: NATAL_CHART_SLUGS,
});

try {
await clearStaleCatalogReports({
uid,
profileHash: newHash,
keepSlugs: NATAL_CHART_SLUGS,
});
} catch (staleErr) {
devLog.warn('[generate-mystical] Failed to clear stale catalog reports', staleErr, 'generate-mystical');
}

let natalReady: string[] = [];
let natalFailed: string[] = [];
try {
Expand Down
13 changes: 11 additions & 2 deletions hooks/useComprehensiveMysticalProfile.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client'

import { useMysticalProfileContext } from '@/contexts/MysticalProfileContext'
import { classifyToolReportState } from '@/lib/toolReportReadiness'
import { useAuth } from '@/hooks/use-auth'
import { classifyToolReportState, reportMatchesProfileHash } from '@/lib/toolReportReadiness'
import type { PersistedToolStatus } from '@/lib/mysticalStageB'

export type { ComprehensiveMysticalProfile } from '@/contexts/MysticalProfileContext'
Expand All @@ -14,6 +15,7 @@ export function useComprehensiveMysticalProfile() {

export function useToolReport(toolSlug: string) {
const { profile, loading, error, isReportsStale, refreshProfile } = useMysticalProfileContext()
const { userProfile } = useAuth()
const p = profile as Record<string, unknown> | null
// Resolve from both shapes: top-level (e.g. profile.western) or toolReports[slug].data
const toolReports = p != null ? (p.toolReports as Record<string, { data?: unknown }> | undefined) : undefined
Expand All @@ -26,18 +28,25 @@ export function useToolReport(toolSlug: string) {
const toolStatusMap = (p != null ? (p.toolStatus as Record<string, PersistedToolStatus> | undefined) : undefined) ?? {}
const persistedStatus = toolStatusMap[toolSlug]
const reportState = classifyToolReportState(report, toolSlug)
const profileHash =
(typeof userProfile?.profileDataHash === 'string' && userProfile.profileDataHash) ||
(typeof p?.profileDataHash === 'string' ? p.profileDataHash : undefined)
const matchesCurrentHash = reportMatchesProfileHash(report, profileHash)
// Prefer live classification — stale toolStatus "ready" must not unlock blank shells.
let state = persistedStatus?.state ?? reportState
if (state === 'ready' && reportState !== 'ready') {
state = reportState
}
if (state === 'ready' && !matchesCurrentHash) {
state = 'pending'
}
const updatedAt = persistedStatus?.updatedAt ?? persistedStatus?.generatedAt
const generatedAt = persistedStatus?.generatedAt
return {
report: report ?? undefined,
loading,
error,
hasReport: report !== undefined && report !== null && state === 'ready',
hasReport: report !== undefined && report !== null && state === 'ready' && matchesCurrentHash,
reportState,
reportStatus: persistedStatus,
reportStateResolved: state,
Expand Down
34 changes: 28 additions & 6 deletions lib/mainSeerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@

import { getDocument } from '@/lib/firebase-admin'
import type { UserProfile } from '@/lib/firebase'
import { ALL_TOOL_SLUGS, isReadyToolReport, summarizeToolReadiness } from '@/lib/toolReportReadiness'
import {
ALL_TOOL_SLUGS,
isCurrentReadyToolReport,
summarizeToolReadiness,
} from '@/lib/toolReportReadiness'
import { wantsDeeperSeerAnswer } from '@/lib/seerChatVoice'

const SLICE_CHARS_DEFAULT = 1_800
Expand Down Expand Up @@ -99,14 +103,25 @@ export function compactReportSlice(value: unknown, maxChars: number): string {
function resolveStoredReport(
comprehensive: Record<string, unknown>,
slug: string,
profileHash?: string,
): Record<string, unknown> | null {
const nested = comprehensive.toolReports as Record<string, { data?: unknown }> | undefined
const val = comprehensive[slug] ?? nested?.[slug]?.data
if (!val || typeof val !== 'object' || Array.isArray(val)) return null
if (!isReadyToolReport(val, slug)) return null
if (!isCurrentReadyToolReport(val, profileHash, slug)) return null
return val as Record<string, unknown>
}

function resolveProfileHash(
profile: UserProfile | null,
comprehensive: Record<string, unknown>,
): string | undefined {
if (typeof profile?.profileDataHash === 'string' && profile.profileDataHash) {
return profile.profileDataHash
}
return typeof comprehensive.profileDataHash === 'string' ? comprehensive.profileDataHash : undefined
}

export function formatReadyToolsIndex(
readySlugs: readonly string[],
pendingSlugs: readonly string[],
Expand Down Expand Up @@ -139,22 +154,29 @@ export async function loadMainSeerContext(params: {
const wantsDeep = wantsDeeperSeerAnswer(question)
const comprehensive = ((await getDocument('comprehensiveMysticalProfiles', userId)) ||
{}) as Record<string, unknown>
const profileHash = resolveProfileHash(profile, comprehensive)
const readiness = summarizeToolReadiness(comprehensive, ALL_TOOL_SLUGS)
const readySlugs = ALL_TOOL_SLUGS.filter((slug) => !readiness.pendingToolSlugs.includes(slug))
const readySlugs = ALL_TOOL_SLUGS.filter(
(slug) => resolveStoredReport(comprehensive, slug, profileHash) != null,
)
const pendingSlugs = ALL_TOOL_SLUGS.filter((slug) => !readySlugs.includes(slug))
const droppedStaleReady = readiness.pendingToolSlugs.length < pendingSlugs.length
const selectedSlugs = pickRelevantToolSlugs(question, readySlugs, { deeper: wantsDeep })
const sliceChars = wantsDeep ? SLICE_CHARS_DEEP : SLICE_CHARS_DEFAULT

const slices = selectedSlugs
.map((slug) => {
const report = resolveStoredReport(comprehensive, slug)
const report = resolveStoredReport(comprehensive, slug, profileHash)
if (!report) return null
const text = compactReportSlice(report, sliceChars)
return text ? `### ${slug}\n${text}` : null
})
.filter((block): block is string => Boolean(block))

let seerMaster = ((await getDocument('seerMaster', userId)) || null) as Record<string, unknown> | null
if (!seerMaster || Object.keys(seerMaster).length === 0) {
if (droppedStaleReady) {
seerMaster = null
} else if (!seerMaster || Object.keys(seerMaster).length === 0) {
const nested = comprehensive.seerMaster
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
seerMaster = nested as Record<string, unknown>
Expand All @@ -164,7 +186,7 @@ export async function loadMainSeerContext(params: {
return {
identityText: buildIdentityDossier(profile),
seerMasterText: formatSeerMasterForPrompt(seerMaster),
readyIndexText: formatReadyToolsIndex(readySlugs, readiness.pendingToolSlugs),
readyIndexText: formatReadyToolsIndex(readySlugs, pendingSlugs),
reportSlicesText:
slices.length > 0
? `Relevant stored reports for this question:\n${slices.join('\n\n')}`
Expand Down
35 changes: 27 additions & 8 deletions lib/mainSeerTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { getDocument } from '@/lib/firebase-admin';
import { searchKnowledge, formatKnowledgeForPrompt } from '@/lib/knowledgeLoader';
import {
ALL_TOOL_SLUGS,
isCurrentReadyToolReport,
isReadyToolReport,
summarizeToolReadiness,
} from '@/lib/profileGenerationOrchestrator';
import { truncateToTokenBudget } from '@/lib/aiTokenBudget';

Expand Down Expand Up @@ -90,13 +90,18 @@ export function isMainSeerToolName(name: string): name is MainSeerToolName {
function resolveToolReport(
profile: Record<string, unknown>,
toolSlug: string,
profileHash?: string,
): Record<string, unknown> | null {
const nested = profile.toolReports as Record<string, { data?: unknown }> | undefined;
const val = profile[toolSlug] ?? nested?.[toolSlug]?.data;
if (!val || typeof val !== 'object' || !isReadyToolReport(val)) return null;
if (!val || typeof val !== 'object' || !isCurrentReadyToolReport(val, profileHash, toolSlug)) return null;
return val as Record<string, unknown>;
}

function profileHashFromStored(profile: Record<string, unknown>): string | undefined {
return typeof profile.profileDataHash === 'string' ? profile.profileDataHash : undefined;
}

function compactJson(value: unknown, maxChars: number): string {
const raw = JSON.stringify(value);
if (raw.length <= maxChars) return raw;
Expand All @@ -112,16 +117,30 @@ export async function executeMainSeerTool(
case 'list_ready_tools': {
const profile = ((await getDocument('comprehensiveMysticalProfiles', userId)) ||
{}) as Record<string, unknown>;
const readiness = summarizeToolReadiness(profile, ALL_TOOL_SLUGS);
const readyTools = ALL_TOOL_SLUGS.filter((slug) => !readiness.pendingToolSlugs.includes(slug));
const profileHash = profileHashFromStored(profile);
const readyTools = ALL_TOOL_SLUGS.filter(
(slug) => resolveToolReport(profile, slug, profileHash) != null,
);
const pendingToolSlugs = ALL_TOOL_SLUGS.filter((slug) => !readyTools.includes(slug));
return {
readyTools,
readyCount: readiness.readyToolsCount,
pendingToolSlugs: readiness.pendingToolSlugs,
allReportsReady: readiness.allReportsReady,
readyCount: readyTools.length,
pendingToolSlugs,
allReportsReady: pendingToolSlugs.length === 0,
};
}
case 'get_seer_master_summary': {
const storedProfile = ((await getDocument('comprehensiveMysticalProfiles', userId)) ||
{}) as Record<string, unknown>;
const profileHash = profileHashFromStored(storedProfile);
const hasStaleReadyReport = ALL_TOOL_SLUGS.some((slug) => {
const nested = storedProfile.toolReports as Record<string, { data?: unknown }> | undefined;
const val = storedProfile[slug] ?? nested?.[slug]?.data;
return isReadyToolReport(val, slug) && resolveToolReport(storedProfile, slug, profileHash) == null;
});
if (hasStaleReadyReport) {
return { found: false, message: 'Seer Master summary is stale after a profile change.' };
}
const seerMaster = ((await getDocument('seerMaster', userId)) || null) as Record<
string,
unknown
Expand All @@ -141,7 +160,7 @@ export async function executeMainSeerTool(
}
const profile = ((await getDocument('comprehensiveMysticalProfiles', userId)) ||
{}) as Record<string, unknown>;
const report = resolveToolReport(profile, toolSlug);
const report = resolveToolReport(profile, toolSlug, profileHashFromStored(profile));
if (!report) {
return { found: false, toolSlug, message: 'Report not ready or not found.' };
}
Expand Down
26 changes: 25 additions & 1 deletion lib/onDemandToolReports.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'server-only';

import { getDocument, setDocument } from '@/lib/firebase-admin';
import { deleteDocument, getDocument, setDocument } from '@/lib/firebase-admin';
import { buildStaleCatalogClearPatch } from '@/lib/staleCatalogReports';
import type { UserProfile } from '@/lib/firebase';
import {
ALL_TOOL_SLUGS,
Expand Down Expand Up @@ -147,6 +148,29 @@ export async function generateAndPersistToolReports(params: {
};
}

/**
* Drop catalog reports (and Seer Master) that belong to a previous profile hash.
* Natal charts for the new hash are written afterwards by persistOnDemandToolReports.
*/
export async function clearStaleCatalogReports(params: {
uid: string;
profileHash: string;
keepSlugs?: readonly string[];
}): Promise<void> {
const { uid, profileHash, keepSlugs = [] } = params;
const existingProfile = ((await getDocument('comprehensiveMysticalProfiles', uid)) ||
{}) as Record<string, unknown>;
const patch = buildStaleCatalogClearPatch(existingProfile, profileHash, keepSlugs);
if (patch) {
await setDocument('comprehensiveMysticalProfiles', uid, patch);
}
const seerMaster = await getDocument('seerMaster', uid);
if (seerMaster) {
await deleteDocument('seerMaster', uid);
}
clearCachedDivinationData(uid);
}

export function storedReportMatchesHash(
report: unknown,
profileHash: string,
Expand Down
4 changes: 4 additions & 0 deletions lib/profileGenerationOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import {
classifyToolReportState,
getCoreToolSlugsCore10,
hasDisplayableReportSubstance,
isCurrentReadyToolReport,
isReadyToolReport,
reportMatchesProfileHash,
summarizeToolReadiness,
type ReportReadinessState,
} from '@/lib/toolReportReadiness';
Expand All @@ -35,7 +37,9 @@ export {
classifyToolReportState,
getCoreToolSlugsCore10,
hasDisplayableReportSubstance,
isCurrentReadyToolReport,
isReadyToolReport,
reportMatchesProfileHash,
summarizeToolReadiness,
};
export type { ReportReadinessState };
Expand Down
96 changes: 96 additions & 0 deletions lib/staleCatalogReports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Pure helpers to drop catalog reports that belong to a previous profile hash.
* Used when Generate Full Report commits a new natal hash so other tools are
* not shown or packed as if they still match the new birth data.
*/

import { ALL_TOOL_SLUGS } from '@/lib/toolReportReadiness';

/** Synthesis / derived keys that must not survive a profile-hash change. */
export const STALE_CATALOG_EXTRA_KEYS = [
'interpretations',
'seerMaster',
'vedicAstroNumerology',
'astroNumerology',
] as const;

function generationKey(report: unknown): string | null {
if (!report || typeof report !== 'object' || Array.isArray(report)) return null;
const key = (report as { generationIdempotencyKey?: unknown }).generationIdempotencyKey;
return typeof key === 'string' && key.length > 0 ? key : null;
}

function reportBelongsToHash(report: unknown, profileHash: string): boolean {
const key = generationKey(report);
return key === profileHash;
}

function nestedReportData(entry: unknown): unknown {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
const data = (entry as { data?: unknown }).data;
return data !== undefined ? data : entry;
}

/**
* Build a merge patch that nulls catalog fields whose generation key does not
* match `profileHash`. Missing keys are treated as stale on hash change.
*/
export function buildStaleCatalogClearPatch(
existingProfile: Record<string, unknown>,
profileHash: string,
keepSlugs: readonly string[] = [],
now = Date.now(),
): Record<string, unknown> | null {
const keep = new Set(keepSlugs);
const patch: Record<string, unknown> = {};

for (const slug of ALL_TOOL_SLUGS) {
if (keep.has(slug)) continue;
const report = existingProfile[slug];
if (report == null) continue;
if (reportBelongsToHash(report, profileHash)) continue;
patch[slug] = null;
}

const nested = existingProfile.toolReports;
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
const nextNested = { ...(nested as Record<string, unknown>) };
let nestedChanged = false;
for (const slug of ALL_TOOL_SLUGS) {
if (keep.has(slug) || nextNested[slug] == null) continue;
if (reportBelongsToHash(nestedReportData(nextNested[slug]), profileHash)) continue;
delete nextNested[slug];
nestedChanged = true;
}
if (nestedChanged) patch.toolReports = nextNested;
}

const existingStatus = existingProfile.toolStatus;
if (existingStatus && typeof existingStatus === 'object' && !Array.isArray(existingStatus)) {
const nextStatus: Record<string, unknown> = { ...(existingStatus as Record<string, unknown>) };
let statusChanged = false;
for (const slug of ALL_TOOL_SLUGS) {
if (keep.has(slug) || patch[slug] !== null) continue;
if (nextStatus[slug] == null) continue;
const prev =
typeof nextStatus[slug] === 'object' && nextStatus[slug] !== null
? (nextStatus[slug] as Record<string, unknown>)
: {};
nextStatus[slug] = {
...prev,
state: 'pending',
updatedAt: now,
error: null,
unchanged: false,
};
statusChanged = true;
}
if (statusChanged) patch.toolStatus = nextStatus;
}

for (const key of STALE_CATALOG_EXTRA_KEYS) {
if (existingProfile[key] != null) patch[key] = null;
}

return Object.keys(patch).length > 0 ? patch : null;
}
Loading
Loading