add doctor feedback

This commit is contained in:
Danel Kungla
2025-10-28 16:09:06 +02:00
parent b5b01648fc
commit 8bc6089a7f
28 changed files with 820 additions and 95 deletions

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Circle } from 'lucide-react';
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { cn } from '@kit/ui/lib/utils';
import { PageBody } from '@kit/ui/makerkit/page';
import { Trans } from '@kit/ui/makerkit/trans';
import { Skeleton } from '@kit/ui/shadcn/skeleton';
@@ -13,6 +13,7 @@ import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
import { getLatestResponseTime } from '~/lib/utils';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import { loadLifeStyle } from '../../_lib/server/load-life-style';
@@ -31,14 +32,22 @@ async function LifeStylePage() {
if (!account) {
return null;
}
const data = await loadLifeStyle(account);
const client = getSupabaseServerClient();
const userAnalysesApi = createUserAnalysesApi(client);
const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses();
const currentAIResponseTimestamp = getLatestResponseTime(analysisResponses);
const { response } = await loadLifeStyle({
account,
analysisResponses,
aiResponseTimestamp: currentAIResponseTimestamp,
});
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_LIFE_STYLE,
});
if (!data.lifestyle) {
if (!response.lifestyle) {
return <Skeleton className="mt-10 h-10 w-full" />;
}
@@ -51,16 +60,10 @@ async function LifeStylePage() {
<PageBody>
<div className="mt-8">
{data.lifestyle.map(({ title, description, score }, index) => (
{response.lifestyle.map(({ title, description }, index) => (
<React.Fragment key={`${index}-${title}`}>
<div className="flex items-center gap-2">
<h3>{title}</h3>
<Circle
className={cn('text-success fill-success size-6', {
'text-warning fill-warning': score === 1,
'text-destructive fill-destructive': score === 2,
})}
/>
</div>
<p className="font-regular py-4">{description}</p>
</React.Fragment>

View File

@@ -41,7 +41,10 @@ const AIBlocks = async ({
return (
<Suspense fallback={<RecommendationsSkeleton />}>
<LifeStyleCard account={account} analysisResponses={analysisResponses} />
<Recommendations account={account} />
<Recommendations
account={account}
analysisResponses={analysisResponses}
/>
</Suspense>
);
};

View File

@@ -12,6 +12,8 @@ import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import { Card, CardHeader } from '@kit/ui/shadcn/card';
import { getLatestResponseTime } from '~/lib/utils';
import { loadLifeStyle } from '../../_lib/server/load-life-style';
import { AnalysisResponses } from './types';
@@ -22,21 +24,31 @@ const LifeStyleCard = async ({
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
}) => {
const data = await loadLifeStyle(account, analysisResponses);
const aiResponseTimestamp = getLatestResponseTime(analysisResponses);
const { response, dateCreated } = await loadLifeStyle({
account,
analysisResponses,
aiResponseTimestamp,
});
return (
<Card variant="gradient-success" className="flex flex-col justify-between">
<CardHeader className="flex-row justify-between">
<h5>
<Trans i18nKey="dashboard:heroCard.lifeStyle.title" />
</h5>
<div>
<span className="text-xs">
{new Date(dateCreated).toLocaleString()}
</span>
<h5 className="flex flex-col">
<Trans i18nKey="dashboard:heroCard.lifeStyle.title" />
</h5>
</div>
<Link href={pathsConfig.app.lifeStyle}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
</CardHeader>
<span className="text-primary p-4 text-sm">{data.summary}</span>
<span className="text-primary p-4 text-sm">{response.summary}</span>
</Card>
);
};

View File

@@ -4,18 +4,29 @@ import React from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { getLatestResponseTime } from '~/lib/utils';
import { loadAnalyses } from '../../_lib/server/load-analyses';
import { loadRecommendations } from '../../_lib/server/load-recommendations';
import OrderAnalysesCards from '../order-analyses-cards';
import { AnalysisResponses } from './types';
export default async function Recommendations({
account,
analysisResponses,
}: {
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
}) {
const { analyses, countryCode } = await loadAnalyses();
const analysisRecommendations = await loadRecommendations(account);
const currentAIResponseTimestamp = getLatestResponseTime(analysisResponses);
const analysisRecommendations = await loadRecommendations({
account,
analyses,
analysisResponses,
aiResponseTimestamp: currentAIResponseTimestamp,
});
const orderAnalyses = analyses.filter((analysis) =>
analysisRecommendations.includes(analysis.title),
);

View File

@@ -12,6 +12,7 @@ export interface ILifeStyleResponse {
export enum PROMPT_NAME {
LIFE_STYLE = 'Life Style',
ANALYSIS_RECOMMENDATIONS = 'Analysis Recommendations',
FEEDBACK = 'Doctor Feedback',
}
export type AnalysisResponses =

View File

@@ -82,8 +82,8 @@ export function HomeMobileNavigation(props: {
const hasDoctorRole =
personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
return hasDoctorRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
return hasDoctorRole;
}, [personalAccountData]);
const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;

View File

@@ -1,6 +1,11 @@
'use server';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import {
AnalysisResponse,
Patient,
} from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import { getLogger } from '@/packages/shared/src/logger';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import OpenAI from 'openai';
@@ -13,26 +18,16 @@ import {
} from '../../_components/ai/types';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
async function getLatestResponseTime(items?: AnalysisResponses) {
if (!items?.length) return null;
let latest = null;
for (const it of items) {
const d = new Date(it.response_time);
const t = d.getTime();
if (!Number.isNaN(t) && (latest === null || t > latest.getTime())) {
latest = d;
}
}
return latest;
}
export async function updateLifeStyle({
account,
analysisResponses,
isDoctorView = false,
aiResponseTimestamp,
}: {
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
isDoctorView?: boolean;
aiResponseTimestamp: string;
}): Promise<ILifeStyleResponse> {
const LIFE_STYLE_PROMPT_ID = process.env.PROMPT_ID_LIFE_STYLE;
if (!LIFE_STYLE_PROMPT_ID || !account?.personal_code) {
@@ -65,11 +60,6 @@ export async function updateLifeStyle({
?.find((ar) => ar.analysis_name_lab === 'Vitamiin D (25-OH)')
?.response_value.toString() || 'unknown';
const latestResponseTime = await getLatestResponseTime(analysisResponses);
const latestISO = latestResponseTime
? new Date(latestResponseTime).toISOString()
: new Date('2025').toISOString();
try {
const response = await openAIClient.responses.create({
store: false,
@@ -106,8 +96,9 @@ export async function updateLifeStyle({
vitamind,
is_smoker: isSmoker.toString(),
}),
latest_data_change: latestISO,
latest_data_change: aiResponseTimestamp,
response: response.output_text,
is_visible_to_customer: !isDoctorView,
});
const json = JSON.parse(response.output_text);
@@ -126,10 +117,12 @@ export async function updateRecommendations({
analyses,
analysisResponses,
account,
aiResponseTimestamp,
}: {
analyses: OrderAnalysisCard[];
analysisResponses?: AnalysisResponses;
account: AccountWithParams;
aiResponseTimestamp: string;
}) {
const RECOMMENDATIONS_PROMPT_IT =
process.env.PROMPT_ID_ANALYSIS_RECOMMENDATIONS;
@@ -164,10 +157,6 @@ export async function updateRecommendations({
description,
title,
}));
const latestResponseTime = await getLatestResponseTime(analysisResponses);
const latestISO = latestResponseTime
? new Date(latestResponseTime).toISOString()
: new Date('2025').toISOString();
try {
const response = await openAIClient.responses.create({
@@ -198,8 +187,9 @@ export async function updateRecommendations({
age,
weight,
}),
latest_data_change: latestISO,
latest_data_change: aiResponseTimestamp,
response: response.output_text,
is_visible_to_customer: false,
});
const json = JSON.parse(response.output_text);
@@ -210,3 +200,125 @@ export async function updateRecommendations({
return [];
}
}
export async function generateDoctorFeedback({
patient,
analysisResponses,
aiResponseTimestamp,
}: {
patient: Patient;
analysisResponses: AnalysisResponse[];
aiResponseTimestamp: string;
}): Promise<string> {
const DOCTOR_FEEDBACK_PROMPT_ID = process.env.PROMPT_ID_DOCTOR_FEEDBACK;
if (!DOCTOR_FEEDBACK_PROMPT_ID) {
console.error('No secrets for doctor feedback');
return '';
}
const openAIClient = new OpenAI();
const supabaseClient = getSupabaseServerClient();
const formattedAnalysisResponses = analysisResponses?.map(
({
analysis_name,
response_value,
norm_upper,
norm_lower,
norm_status,
}) => ({
name: analysis_name,
value: response_value,
normUpper: norm_upper,
normLower: norm_lower,
normStatus: norm_status,
}),
);
try {
const response = await openAIClient.responses.create({
store: false,
prompt: {
id: DOCTOR_FEEDBACK_PROMPT_ID,
variables: {
analysesresults: JSON.stringify(formattedAnalysisResponses),
},
},
});
await supabaseClient
.schema('medreport')
.from('ai_responses')
.insert({
account_id: patient.userId,
prompt_name: PROMPT_NAME.FEEDBACK,
prompt_id: DOCTOR_FEEDBACK_PROMPT_ID,
input: JSON.stringify({
analysesresults: formattedAnalysisResponses,
}),
latest_data_change: aiResponseTimestamp,
response: response.output_text,
});
return response.output_text;
} catch (error) {
console.error('Error getting doctor feedback: ', error);
return '';
}
}
export async function confirmPatientAIResponses(
patientId: string,
aiResponseTimestamp: string,
recommendations: string[],
isRecommendationsEdited: boolean,
) {
const logger = await getLogger();
const supabaseClient = getSupabaseServerClient();
const { error } = await supabaseClient
.schema('medreport')
.from('ai_responses')
.update({
is_visible_to_customer: true,
})
.eq('latest_data_change', aiResponseTimestamp)
.eq('account_id', patientId)
.eq('prompt_name', PROMPT_NAME.LIFE_STYLE);
if (error) {
logger.error(
{ error, patientId, aiResponseTimestamp },
'Failed updating life style',
);
}
const { error: _error } = await supabaseClient
.schema('medreport')
.from('ai_responses')
.update({
is_visible_to_customer: true,
...(isRecommendationsEdited && {
response: JSON.stringify({
why: 'This was edited by doctor',
recommended: recommendations,
}),
}),
})
.eq('latest_data_change', aiResponseTimestamp)
.eq('account_id', patientId)
.eq('prompt_name', PROMPT_NAME.ANALYSIS_RECOMMENDATIONS);
if (_error) {
logger.error(
{
error,
aiResponseTimestamp,
patientId,
isRecommendationsEdited,
},
'Failed updating analysis recommendations',
);
}
}

View File

@@ -1,6 +1,7 @@
import { cache } from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { getLogger } from '@/packages/shared/src/logger';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import {
@@ -11,28 +12,52 @@ import {
import { updateLifeStyle } from './ai-actions';
const failedResponse = {
lifestyle: [],
summary: null,
response: {
lifestyle: [],
summary: null,
},
dateCreated: new Date().toISOString(),
};
async function lifeStyleLoader(
account: AccountWithParams,
analysisResponses?: AnalysisResponses,
): Promise<ILifeStyleResponse> {
async function lifeStyleLoader({
account,
analysisResponses,
isDoctorView = false,
aiResponseTimestamp,
}: {
account: AccountWithParams | null;
analysisResponses?: AnalysisResponses;
isDoctorView?: boolean;
aiResponseTimestamp: string;
}): Promise<{ response: ILifeStyleResponse; dateCreated: string }> {
const logger = await getLogger();
if (!account?.personal_code) {
return failedResponse;
}
const supabaseClient = getSupabaseServerClient();
const { data, error } = await supabaseClient
const query = supabaseClient
.schema('medreport')
.from('ai_responses')
.select('response')
.select('response, latest_data_change')
.eq('account_id', account.id)
.eq('prompt_name', PROMPT_NAME.LIFE_STYLE)
.order('latest_data_change', { ascending: false, nullsFirst: false })
.limit(1)
.maybeSingle();
.eq('prompt_name', PROMPT_NAME.LIFE_STYLE);
if (isDoctorView) {
logger.info(
{ aiResponseTimestamp, accountId: account.id },
'Attempting to receive life style row',
);
query.eq('latest_data_change', aiResponseTimestamp);
} else {
query
.eq('is_visible_to_customer', true)
.order('latest_data_change', { ascending: false, nullsFirst: false });
}
const { data, error } = await query.limit(1).maybeSingle();
logger.info({ data: !!data }, 'Existing life style row');
if (error) {
console.error('Error fetching AI response from DB: ', error);
@@ -40,9 +65,19 @@ async function lifeStyleLoader(
}
if (data?.response) {
return JSON.parse(data.response as string);
return {
response: JSON.parse(data.response as string),
dateCreated: data.latest_data_change,
};
} else {
return await updateLifeStyle({ account, analysisResponses });
const newLifeStyle = await updateLifeStyle({
account,
analysisResponses,
isDoctorView,
aiResponseTimestamp,
});
return { response: newLifeStyle, dateCreated: aiResponseTimestamp };
}
}
export const loadLifeStyle = cache(lifeStyleLoader);

View File

@@ -1,29 +1,57 @@
import { cache } from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { getLogger } from '@/packages/shared/src/logger';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { PROMPT_NAME } from '../../_components/ai/types';
import { AnalysisResponses, PROMPT_NAME } from '../../_components/ai/types';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
import { updateRecommendations } from './ai-actions';
export const loadRecommendations = cache(recommendationsLoader);
async function recommendationsLoader(
account: AccountWithParams | null,
): Promise<string[]> {
async function recommendationsLoader({
account,
isDoctorView = false,
analyses,
analysisResponses,
aiResponseTimestamp,
}: {
account: AccountWithParams | null;
isDoctorView?: boolean;
analyses: OrderAnalysisCard[];
analysisResponses?: AnalysisResponses;
aiResponseTimestamp: string;
}): Promise<string[]> {
const logger = await getLogger();
if (!account?.personal_code) {
return [];
}
const supabaseClient = getSupabaseServerClient();
const { data, error } = await supabaseClient
const query = supabaseClient
.schema('medreport')
.from('ai_responses')
.select('*')
.eq('account_id', account.id)
.eq('prompt_name', PROMPT_NAME.ANALYSIS_RECOMMENDATIONS)
.order('latest_data_change', { ascending: false, nullsFirst: false })
.limit(1)
.maybeSingle();
.eq('prompt_name', PROMPT_NAME.ANALYSIS_RECOMMENDATIONS);
logger.info(
{ accountId: account.id, isDoctorView, aiResponseTimestamp },
'Attempting to receive existing recommendations',
);
if (isDoctorView) {
query.eq('latest_data_change', aiResponseTimestamp);
} else {
query
.eq('is_visible_to_customer', true)
.order('latest_data_change', { ascending: false, nullsFirst: false });
}
const { data, error } = await query.limit(1).maybeSingle();
logger.info({ data: data }, 'Existing recommendations');
if (error) {
console.error('Error fetching AI response from DB: ', error);
@@ -33,6 +61,14 @@ async function recommendationsLoader(
if (data?.response) {
return JSON.parse(data.response as string).recommended;
} else {
if (isDoctorView) {
return await updateRecommendations({
account,
analyses,
analysisResponses,
aiResponseTimestamp,
});
}
return [];
}
}