send analyses to ai

This commit is contained in:
Danel Kungla
2025-09-19 16:54:21 +03:00
parent 1b634f9736
commit addd3a5dae
4 changed files with 66 additions and 230 deletions

View File

@@ -1,3 +1,5 @@
import { Suspense } from 'react';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { toTitleCase } from '@/lib/utils'; import { toTitleCase } from '@/lib/utils';
@@ -13,10 +15,8 @@ import { withI18n } from '~/lib/i18n/with-i18n';
import Dashboard from '../_components/dashboard'; import Dashboard from '../_components/dashboard';
import DashboardCards from '../_components/dashboard-cards'; import DashboardCards from '../_components/dashboard-cards';
import Recommendations from '../_components/recommendations'; import Recommendations from '../_components/recommendations';
import { loadAnalyses } from '../_lib/server/load-analyses';
import { loadRecommendations } from '../_lib/server/load-recommendations';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
import { analysisResponses } from './analysis-results/test/test-responses'; import Loading from '../loading';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();
@@ -28,21 +28,11 @@ export const generateMetadata = async () => {
}; };
async function UserHomePage() { async function UserHomePage() {
console.log('process.env', process.env.OPENAI_API_KEY);
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { account } = await loadCurrentUserAccount(); const { account } = await loadCurrentUserAccount();
const api = createUserAnalysesApi(client); const api = createUserAnalysesApi(client);
const bmiThresholds = await api.fetchBmiThresholds(); const bmiThresholds = await api.fetchBmiThresholds();
const { analyses, countryCode } = await loadAnalyses();
const analysisRecommendations = await loadRecommendations(
analysisResponses,
analyses,
account,
);
console.log('analysisRecommendations', analysisRecommendations);
if (!account) { if (!account) {
redirect('/'); redirect('/');
@@ -62,13 +52,9 @@ async function UserHomePage() {
/> />
<PageBody> <PageBody>
<Dashboard account={account} bmiThresholds={bmiThresholds} /> <Dashboard account={account} bmiThresholds={bmiThresholds} />
<Suspense fallback={null}>
<Recommendations <Recommendations account={account} />
recommended={analyses.filter((analysis) => </Suspense>
analysisRecommendations.includes(analysis.title),
)}
countryCode={countryCode}
/>
</PageBody> </PageBody>
</> </>
); );

View File

@@ -1,24 +1,40 @@
'use client'; 'use server';
import React from 'react'; import React, { useState } from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { analysisResponses } from '../(dashboard)/analysis-results/test/test-responses';
import { loadAnalyses } from '../_lib/server/load-analyses';
import { loadRecommendations } from '../_lib/server/load-recommendations';
import OrderAnalysesCards, { OrderAnalysisCard } from './order-analyses-cards'; import OrderAnalysesCards, { OrderAnalysisCard } from './order-analyses-cards';
export default function Recommendations({ export default async function Recommendations({
recommended, account,
countryCode,
}: { }: {
recommended: OrderAnalysisCard[]; account: AccountWithParams;
countryCode: string;
}) { }) {
if (recommended.length < 1) { const { analyses, countryCode } = await loadAnalyses();
const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false);
const analysisResults = analysisResponses;
console.log('selectedDate', isLoadingTimeSlots);
const analysisRecommendations = await loadRecommendations(
analysisResults,
analyses,
account,
);
const orderAnalyses = analyses.filter((analysis) =>
analysisRecommendations.includes(analysis.title),
);
console.log('analysisRecommendations', analysisRecommendations);
if (orderAnalyses.length < 1) {
return null; return null;
} }
return ( return (
<div> <div>
<h4>Medreport soovitab teile</h4> <h4>Medreport soovitab teile</h4>
<OrderAnalysesCards analyses={recommended} countryCode={countryCode} /> <OrderAnalysesCards analyses={orderAnalyses} countryCode={countryCode} />
</div> </div>
); );
} }

View File

@@ -1,133 +0,0 @@
import type { Mock } from 'jest-mock';
// ---- Mocks you can tweak per test ----
const createResponseMock = jest.fn();
const getLatestResponseTimeMock = jest.fn();
const getLatestUniqueAnalysResponsesMock = jest.fn();
const parsePersonalCodeMock = jest.fn(() => ({ gender: { value: 'male' } }));
// Mock OpenAI SDK
jest.mock('openai', () => {
return {
__esModule: true,
default: class OpenAI {
responses = { create: createResponseMock };
},
};
});
// Mock next/cache (global cache map so it persists between calls)
const globalCache = new Map<string, unknown>();
jest.mock('next/cache', () => {
return {
__esModule: true,
unstable_cache:
(fn: (...args: any[]) => Promise<any>, keyParts: any[], _opts?: any) =>
async (...args: any[]) => {
const key = JSON.stringify(keyParts);
if (globalCache.has(key)) return globalCache.get(key);
const val = await fn(...args);
globalCache.set(key, val);
return val;
},
};
});
// Mock your analysis helpers + personal code parser
jest.mock('../src/analysis-utils', () => ({
__esModule: true,
getLatestUniqueAnalysResponses: getLatestUniqueAnalysResponsesMock,
getLatestResponseTime: getLatestResponseTimeMock,
}));
jest.mock('../src/personal-code', () => ({
__esModule: true,
PersonalCode: { parsePersonalCode: parsePersonalCodeMock },
}));
describe('loadRecommendations', () => {
beforeEach(() => {
createResponseMock.mockReset();
getLatestResponseTimeMock.mockReset();
getLatestUniqueAnalysResponsesMock.mockReset();
globalCache.clear();
});
it('should call OpenAI once when latest date stays the same (cache hit on 2nd call)', async () => {
const date1 = new Date('2025-09-16T12:00:00Z');
getLatestResponseTimeMock.mockReturnValue(date1);
getLatestUniqueAnalysResponsesMock.mockImplementation((arr: any[]) => arr);
createResponseMock.mockResolvedValue({
output_text: JSON.stringify({ recommended: ['A', 'B'] }),
});
const { loadRecommendations } = await import('./load-recommendations');
const analysisResponses = [
{ name: 'x', value: 1, responseTime: date1 },
] as any[];
const analyses = [{ title: 't', description: 'd' }] as any[];
const account = { id: 'u1', personal_code: '12345678901' } as any;
// Act: 1st call (MISS)
const out1 = await loadRecommendations(
analysisResponses,
analyses,
account,
);
// Act: 2nd call with same date (HIT)
const out2 = await loadRecommendations(
analysisResponses,
analyses,
account,
);
// Assert: only one API call, result reused
expect(createResponseMock).toHaveBeenCalledTimes(1);
expect(out1).toEqual(['A', 'B']);
expect(out2).toEqual(['A', 'B']);
});
it('should call OpenAI again when latest date changes (new cache key)', async () => {
const date1 = new Date('2025-09-16T12:00:00Z');
const date2 = new Date('2025-09-17T00:00:00Z');
getLatestResponseTimeMock.mockReturnValueOnce(date1);
getLatestResponseTimeMock.mockReturnValueOnce(date2);
getLatestUniqueAnalysResponsesMock.mockImplementation((arr: any[]) => arr);
createResponseMock
.mockResolvedValueOnce({
output_text: JSON.stringify({ recommended: ['A', 'B'] }),
})
.mockResolvedValueOnce({
output_text: JSON.stringify({ recommended: ['C'] }),
});
const { loadRecommendations } = await import('./load-recommendations');
const analysisResponses = [
{ name: 'x', value: 1, responseTime: date1 },
] as any[];
const analyses = [{ title: 't', description: 'd' }] as any[];
const account = { id: 'u1', personal_code: '12345678901' } as any;
const out1 = await loadRecommendations(
analysisResponses,
analyses,
account,
);
const out2 = await loadRecommendations(
analysisResponses,
analyses,
account,
);
expect(createResponseMock).toHaveBeenCalledTimes(2);
expect(out1).toEqual(['A', 'B']);
expect(out2).toEqual(['C']);
});
});

View File

@@ -1,9 +1,6 @@
import { cache } from 'react'; import { cache } from 'react';
import { unstable_cache } from 'next/cache';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts'; import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import crypto from 'crypto';
import OpenAI from 'openai'; import OpenAI from 'openai';
import PersonalCode from '~/lib/utils'; import PersonalCode from '~/lib/utils';
@@ -19,20 +16,6 @@ type FormattedAnalysisResponse = {
responseTime: string; responseTime: string;
}; };
function canonical(v: unknown): string {
return JSON.stringify(v, (_k, val) => {
if (val && typeof val === 'object' && !Array.isArray(val)) {
// sort object keys for stable JSON
return Object.keys(val)
.sort()
.reduce((o: any, k) => ((o[k] = (val as any)[k]), o), {});
}
return val;
});
}
const sha256 = (s: string) =>
crypto.createHash('sha256').update(s).digest('hex');
const getLatestResponseTime = (items: FormattedAnalysisResponse[]) => { const getLatestResponseTime = (items: FormattedAnalysisResponse[]) => {
if (!items?.length) return null; if (!items?.length) return null;
@@ -56,23 +39,29 @@ const getLatestUniqueAnalysResponses = (
console.log('analysisElements', analysisElements.length); console.log('analysisElements', analysisElements.length);
const map = new Map(); const byName = analysisElements.reduce<
for (const it of analysisElements) { Record<string, { name: string; value: string; responseTime: string }>
const prev = map.get(it.analysisName); >((acc, it) => {
if (it.results.responseTime) { const responseTime = it?.results?.responseTime;
if ( const responseValue = it?.results?.responseValue;
!prev || if (!responseTime || !responseValue) return acc;
new Date(it.results.responseTime) > new Date(prev.responseTime)
) { const key = it.analysisName;
map.set(it.analysisName, { const cur = acc[key];
name: it.analysisName, const t = Date.parse(responseTime);
value: it.results.responseValue, const prevT = cur ? Date.parse(cur.responseTime) : -Infinity;
responseTime: it.results.responseTime,
}); if (!cur || t > prevT) {
} acc[key] = {
name: key,
value: responseValue.toString(),
responseTime,
};
} }
} return acc;
return [...map.values()]; }, {});
return Object.values(byName);
}; };
async function recommendationsLoader( async function recommendationsLoader(
@@ -80,6 +69,9 @@ async function recommendationsLoader(
analyses: OrderAnalysisCard[], analyses: OrderAnalysisCard[],
account: AccountWithParams | null, account: AccountWithParams | null,
): Promise<any> { ): Promise<any> {
if (!process.env.OPENAI_API_KEY) {
return [];
}
if (!account?.personal_code) { if (!account?.personal_code) {
return []; return [];
} }
@@ -90,11 +82,11 @@ async function recommendationsLoader(
console.log('analyises', analyses); console.log('analyises', analyses);
const latestUniqueAnalysResponses = const latestUniqueAnalysResponses =
getLatestUniqueAnalysResponses(analysisResponses); getLatestUniqueAnalysResponses(analysisResponses);
const latestResponseTime = getLatestResponseTime(latestUniqueAnalysResponses); // const latestResponseTime = getLatestResponseTime(latestUniqueAnalysResponses);
const latestISO = latestResponseTime // const latestISO = latestResponseTime
? new Date(latestResponseTime).toISOString() // ? new Date(latestResponseTime).toISOString()
: 'none'; // : 'none';
console.log('latestResponseTime', latestResponseTime); // console.log('latestResponseTime', latestResponseTime);
const formattedAnalysisResponses = latestUniqueAnalysResponses.map( const formattedAnalysisResponses = latestUniqueAnalysResponses.map(
({ name, value }) => ({ name, value }), ({ name, value }) => ({ name, value }),
); );
@@ -108,8 +100,9 @@ async function recommendationsLoader(
'latestUniqueAnalysResponses', 'latestUniqueAnalysResponses',
JSON.stringify(latestUniqueAnalysResponses), JSON.stringify(latestUniqueAnalysResponses),
); );
const response = await client.responses.create({ const response = await client.responses.create({
model: 'gpt-5', model: 'gpt-5-mini',
store: false, store: false,
prompt: { prompt: {
id: 'pmpt_68ca9c8bfa8c8193b27eadc6496c36440df449ece4f5a8dd', id: 'pmpt_68ca9c8bfa8c8193b27eadc6496c36440df449ece4f5a8dd',
@@ -121,33 +114,7 @@ async function recommendationsLoader(
}, },
}); });
const responseJson = JSON.parse(response.output_text); const json = JSON.parse(response.output_text);
console.log('responseJson: ', responseJson); console.log('response.output_text', response.output_text);
return json.recommended;
const keyPayload = {
model: 'gpt-5', // swap to a model your project can access
promptId: 'pmpt_68ca9c8bfa8c8193b27eadc6496c36440df449ece4f5a8dd',
latestISO,
};
const key = 'recs:' + sha256(canonical(keyPayload));
const run = unstable_cache(async () => {
const response = await client.responses.create({
model: keyPayload.model,
store: false,
prompt: {
id: keyPayload.promptId,
variables: {
analyses: JSON.stringify(formattedAnalyses),
results: JSON.stringify(latestUniqueAnalysResponses),
gender: gender.value,
},
},
});
const json = JSON.parse(response.output_text);
return json.recommended;
}, ['recommendations', key]);
return await run();
} }