diff --git a/.github/actions/buildAllAndDeploy/action.yml b/.github/actions/buildAllAndDeploy/action.yml index 14c80ea592..5345756331 100644 --- a/.github/actions/buildAllAndDeploy/action.yml +++ b/.github/actions/buildAllAndDeploy/action.yml @@ -17,14 +17,13 @@ inputs: tf-state-bucket: description: "Terraform state bucket" required: true - openai-api-key: - description: "OpenAI API key" - required: false - default: "" ahr-api-key: description: "AHR API key" required: true - + anthropic-api-key: + description: "Claude API key" + required: true + outputs: data-server-url: description: "URL of the deployed data server" @@ -97,13 +96,11 @@ runs: - name: Build and Push Frontend Image id: frontend uses: SatcherInstitute/health-equity-tracker/.github/actions/buildAndPush@main - env: - VITE_OPENAI_API_KEY: ${{ inputs.openai-api-key }} with: dockerfile: "frontend_server/Dockerfile" image-path: "gcr.io/${{ inputs.project-id }}/frontend" deploy-context: ${{ inputs.environment }} - openai-api-key: ${{ inputs.openai-api-key }} + anthropic-api-key: ${{ inputs.anthropic-api-key }} # Terraform and deployment - name: Setup Terraform @@ -140,6 +137,7 @@ runs: -var 'data_server_image_digest=${{ steps.serving.outputs.image-digest }}' \ -var 'exporter_image_digest=${{ steps.exporter.outputs.image-digest }}' \ -var 'frontend_image_digest=${{ steps.frontend.outputs.image-digest }}' \ + -var 'anthropic_api_key=${{ inputs.anthropic-api-key }}' \ data_server_url=$(terraform output data_server_url) echo "data_server_url=$data_server_url" >> $GITHUB_OUTPUT @@ -168,3 +166,4 @@ runs: FRONTEND_URL: ${{ steps.terraform.outputs.frontend_url }} PATH_TO_SA_CREDS: config/creds.json AHR_API_KEY: ${{ inputs.ahr-api-key }} + ANTHROPIC_API_KEY: ${{ inputs.anthropic-api-key }} diff --git a/.github/actions/buildAndPush/action.yml b/.github/actions/buildAndPush/action.yml index 1cd0f9cdf7..ea7c4913a3 100644 --- a/.github/actions/buildAndPush/action.yml +++ b/.github/actions/buildAndPush/action.yml @@ -4,6 +4,9 @@ inputs: ahr-api-key: description: "AHR API key used to fetch AHR data from the GraphQL endpoint" required: false + anthropic-api-key: + description: "Claude API key used to fetch insights from Claude endpoint" + required: false dockerfile: description: "Relative path to dockerfile" required: true @@ -17,9 +20,7 @@ inputs: deploy-context: description: 'String value for deploy context. Should be "prod" or "dev". Only used for the frontend' required: false - openai-api-key: - description: "OpenAI API key for generating insights" - required: false + outputs: image-digest: description: "Digest of image pushed to GCR" @@ -36,7 +37,7 @@ runs: docker build -t ${{ inputs.image-path }} -f ${{ inputs.dockerfile }} ${{ inputs.build-directory }} \ --build-arg="DEPLOY_CONTEXT=${{ inputs.deploy-context }}" \ --build-arg="AHR_API_KEY=${{ inputs.ahr-api-key }}" \ - --build-arg="OPENAI_API_KEY=${{ inputs.openai-api-key }}" + --build-arg="ANTHROPIC_API_KEY"=${{ inputs.anthropic-api-key }} shell: bash - run: docker push ${{ inputs.image-path }} shell: bash diff --git a/.github/workflows/deployInfraTest.yml b/.github/workflows/deployInfraTest.yml index 8c86e33861..210d9d3a39 100644 --- a/.github/workflows/deployInfraTest.yml +++ b/.github/workflows/deployInfraTest.yml @@ -38,4 +38,4 @@ jobs: project-id: ${{ secrets.TEST_PROJECT_ID }} tf-state-bucket: ${{ secrets.TEST_TF_STATE_BUCKET }} ahr-api-key: ${{ secrets.AHR_API_KEY }} - openai-api-key: ${{ secrets.OPENAI_API_KEY }} + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/runDestroyInfraTest.yml b/.github/workflows/runDestroyInfraTest.yml index 044746a5ad..455ad1f218 100644 --- a/.github/workflows/runDestroyInfraTest.yml +++ b/.github/workflows/runDestroyInfraTest.yml @@ -121,7 +121,6 @@ jobs: dockerfile: 'frontend_server/Dockerfile' image-path: 'gcr.io/${{ secrets.TEST_PROJECT_ID }}/frontend' deploy-context: 'dev' - openai-api-key: ${{ secrets.OPENAI_API_KEY }} deploy: if: github.repository == 'SatcherInstitute/health-equity-tracker' diff --git a/.github/workflows/testBackendChangesInfraTest.yml b/.github/workflows/testBackendChangesInfraTest.yml index 8512b799c8..f216796397 100644 --- a/.github/workflows/testBackendChangesInfraTest.yml +++ b/.github/workflows/testBackendChangesInfraTest.yml @@ -26,4 +26,4 @@ jobs: project-id: ${{ secrets.TEST_PROJECT_ID }} tf-state-bucket: ${{ secrets.TEST_TF_STATE_BUCKET }} ahr-api-key: ${{ secrets.AHR_API_KEY }} - openai-api-key: ${{ secrets.OPENAI_API_KEY }} + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/config/run.tf b/config/run.tf index c195732eb1..ff8cf27189 100644 --- a/config/run.tf +++ b/config/run.tf @@ -189,6 +189,11 @@ resource "google_cloud_run_service" "frontend_service" { value = google_cloud_run_service.data_server_service.status.0.url } + env { + name = "ANTHROPIC_API_KEY" + value = var.anthropic_api_key + } + resources { limits = { memory = "8Gi" diff --git a/config/variables.tf b/config/variables.tf index 5277dc8d00..f1aec89a53 100644 --- a/config/variables.tf +++ b/config/variables.tf @@ -175,3 +175,9 @@ variable "frontend_runner_identity_id" { description = "Account id of the service account used when running the frontend service" type = string } + +variable "anthropic_api_key" { + description = "Anthropic API key for AI insights" + type = string + sensitive = true +} diff --git a/frontend/src/cards/CardWrapper.tsx b/frontend/src/cards/CardWrapper.tsx index a598a36146..6a23f66f40 100644 --- a/frontend/src/cards/CardWrapper.tsx +++ b/frontend/src/cards/CardWrapper.tsx @@ -1,14 +1,20 @@ +import { AutoAwesome, DeleteForever } from '@mui/icons-material' import { CircularProgress } from '@mui/material' +import IconButton from '@mui/material/IconButton' +import { useState } from 'react' import type { MetricQuery, MetricQueryResponse, } from '../data/query/MetricQuery' import { WithMetadataAndMetrics } from '../data/react/WithLoadingOrErrorUI' import type { MapOfDatasetMetadata } from '../data/utils/DatasetTypes' +import { splitIntoKnownsAndUnknowns } from '../data/utils/datasetutils' +import type { Fips } from '../data/utils/Fips' import { SHOW_INSIGHT_GENERATION } from '../featureFlags' +import { generateCardInsight } from '../utils/generateCardInsight' import type { ScrollableHashId } from '../utils/hooks/useStepObserver' import CardOptionsMenu from './ui/CardOptionsMenu' -import InsightDisplay from './ui/InsightDisplay' +import InsightCard from './ui/InsightCard' import { Sources } from './ui/Sources' function CardWrapper(props: { @@ -36,7 +42,12 @@ function CardWrapper(props: { shareConfig?: any demographicType?: any metricIds?: any + fips?: Fips }) { + const [insight, setInsight] = useState('') + const [isGeneratingInsight, setIsGeneratingInsight] = useState(false) + const [rateLimitReached, setRateLimitReached] = useState(false) + const loadingComponent = (
{(metadata, queryResponses, geoData) => { + const queryResponse = queryResponses[0] + + const handleGenerateInsight = async () => { + if (!props.shareConfig || !props.metricIds?.length) return + + const validData = queryResponse.getValidRowsForField( + props.shareConfig.metricId, + ) + const [knownData] = splitIntoKnownsAndUnknowns( + validData, + props.demographicType, + ) + if (!knownData.length) return + + setIsGeneratingInsight(true) + try { + const result = await generateCardInsight( + { knownData, metricIds: props.metricIds }, + props.scrollToHash, + props.fips, + ) + if (result.rateLimited) { + setRateLimitReached(true) + } else { + setInsight(result.content) + } + } finally { + setIsGeneratingInsight(false) + } + } + + const handleClearInsight = () => setInsight('') + return (
{shouldShowInsightDisplay && ( - )} - +
+ {showInsightButton && ( + + {isGeneratingInsight ? ( + + ) : insight ? ( + + ) : ( + + )} + + )} + +
+ {props.children(queryResponses, metadata, geoData)} {!props.hideFooter && props.queries && ( {(queryResponses, metadata, geoData) => { // contains rows for sub-geos (if viewing US, this data will be STATE level) diff --git a/frontend/src/cards/RateBarChartCard.tsx b/frontend/src/cards/RateBarChartCard.tsx index 39580c3551..3172dbdc9e 100644 --- a/frontend/src/cards/RateBarChartCard.tsx +++ b/frontend/src/cards/RateBarChartCard.tsx @@ -130,6 +130,9 @@ export default function RateBarChartCard(props: RateBarChartCardProps) { reportTitle={props.reportTitle} className={props.className} hasIntersectionalAllCompareBar={rateComparisonConfig !== undefined} + shareConfig={rateConfig} + metricIds={[rateConfig.metricId]} + fips={props.fips} > {([rateQueryResponseRate, rateQueryResponseRateAlls], metadata) => { // for consistency, filter out any 'Unknown' rows that might have rates (like PHRMA) diff --git a/frontend/src/cards/RateTrendsChartCard.tsx b/frontend/src/cards/RateTrendsChartCard.tsx index 8537185cd3..fe50248fa2 100644 --- a/frontend/src/cards/RateTrendsChartCard.tsx +++ b/frontend/src/cards/RateTrendsChartCard.tsx @@ -135,6 +135,9 @@ export default function RateTrendsChartCard(props: RateTrendsChartCardProps) { reportTitle={props.reportTitle} expanded={a11yTableExpanded} className={props.className} + shareConfig={metricConfigRates} + metricIds={[metricConfigRates.metricId]} + fips={props.fips} > {([queryResponseRates, queryResponsePctShares]) => { let ratesData = queryResponseRates.getValidRowsForField( diff --git a/frontend/src/cards/ShareTrendsChartCard.tsx b/frontend/src/cards/ShareTrendsChartCard.tsx index 5977e20fb5..46e3d526db 100644 --- a/frontend/src/cards/ShareTrendsChartCard.tsx +++ b/frontend/src/cards/ShareTrendsChartCard.tsx @@ -123,6 +123,9 @@ export default function ShareTrendsChartCard(props: ShareTrendsChartCardProps) { reportTitle={props.reportTitle} expanded={a11yTableExpanded} className={props.className} + shareConfig={metricConfigInequitable} + metricIds={[metricConfigInequitable.metricId]} + fips={props.fips} > {([queryResponseInequity, queryResponsePctShares]) => { const inequityData = queryResponseInequity.getValidRowsForField( diff --git a/frontend/src/cards/StackedSharesBarChartCard.tsx b/frontend/src/cards/StackedSharesBarChartCard.tsx index b60ecb608d..23d3358726 100644 --- a/frontend/src/cards/StackedSharesBarChartCard.tsx +++ b/frontend/src/cards/StackedSharesBarChartCard.tsx @@ -91,6 +91,7 @@ export default function StackedSharesBarChartCard( className={props.className} shareConfig={shareConfig} metricIds={metricIds} + fips={props.fips} > {([queryResponse]) => { const validData = queryResponse.getValidRowsForField( diff --git a/frontend/src/cards/generateInsights.tsx b/frontend/src/cards/generateInsights.tsx deleted file mode 100644 index e04677180a..0000000000 --- a/frontend/src/cards/generateInsights.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import type { MetricId } from '../data/config/MetricConfigTypes' -import { SHOW_INSIGHT_GENERATION } from '../featureFlags' -import type { ChartData } from '../reports/Report' -import { - extractRelevantData, - getHighestDisparity, -} from './generateInsightsUtils' - -const API_ENDPOINT = '/fetch-ai-insight' -const ERROR_GENERATING_INSIGHT = 'Error generating insight' - -export type Dataset = Record - -export interface Disparity { - disparity: number - location: string - measure: string - outcomeShare: number - populationShare: number - ratio: number - subgroup: string -} - -export interface ResultData { - fips_name: string - race_and_ethnicity?: string - age?: string | number - sex?: string - [key: string]: any -} - -async function fetchAIInsight(prompt: string): Promise { - const baseApiUrl = import.meta.env.VITE_BASE_API_URL - const dataServerUrl = baseApiUrl - ? `${baseApiUrl}${API_ENDPOINT}` - : API_ENDPOINT - - if (!SHOW_INSIGHT_GENERATION) { - return '' - } - - try { - const dataResponse = await fetch(dataServerUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ prompt }), - }) - - if (!dataResponse.ok) { - throw new Error(`Failed to fetch AI insight: ${dataResponse.statusText}`) - } - - const insight = await dataResponse.json() - const content = insight.content.trim() - - return content - } catch (error) { - console.error('Error generating insight:', error) - return ERROR_GENERATING_INSIGHT - } -} - -function generateInsightPrompt(disparities: Disparity): string { - const { subgroup, location, measure, populationShare, outcomeShare, ratio } = - disparities - - return ` - Given the following disparity data: - Subgroup: ${subgroup} - Location: ${location} - Measure: ${measure} - Population share: ${populationShare}% - Health outcome share: ${outcomeShare}% - Ratio: ${ratio} - - Example: - "In the US, [Subgroup] individuals make up [Population Share]% of the population but account for [Outcome Share]% of [Measure], making them [Ratio] times more likely to [Impact]." - - Guidelines: - - Uses contrasting words like "but" or "while" to emphasize differences. - - Avoids assumptions and reflects the data as presented. - - Uses clear and simple language to make the disparity easily understood. - - Adapt the measure to fit grammatically (e.g., "uninsured cases", "HIV deaths, Black women"). - - Is suitable for use in reports or presentations. - - If measure PrEP, population share is the PrEP eligible population and the measure if PrEP prescriptions. - ` -} - -function mapRelevantData( - dataArray: Dataset[], - metricIds: MetricId[], -): ResultData[] { - return dataArray.map((dataset) => extractRelevantData(dataset, metricIds)) -} - -export async function generateInsight( - chartMetrics: ChartData, -): Promise { - if (!SHOW_INSIGHT_GENERATION) { - return '' - } - - try { - const { knownData, metricIds } = chartMetrics - const processedData = mapRelevantData(knownData, metricIds) - const highestDisparity = getHighestDisparity(processedData) - const insightPrompt = generateInsightPrompt(highestDisparity) - return await fetchAIInsight(insightPrompt) - } catch (error) { - console.error(ERROR_GENERATING_INSIGHT, error) - return ERROR_GENERATING_INSIGHT - } -} diff --git a/frontend/src/cards/generateInsightsUtils.tsx b/frontend/src/cards/generateInsightsUtils.tsx deleted file mode 100644 index 16a389459d..0000000000 --- a/frontend/src/cards/generateInsightsUtils.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { MetricId } from '../data/config/MetricConfigTypes' -import type { Dataset, Disparity, ResultData } from './generateInsights' - -const RATE_LIMIT_ENDPOINT = '/rate-limit-status' - -function getKeyBySubstring(obj: any, substring: string): [string, string] { - const key = Object.keys(obj).find((key) => key.includes(substring)) || '' - let measure = '' - if (key) { - measure = key.replace(/_pct_share$|_population_pct$/, '') - } - return [key, measure] -} - -export function getHighestDisparity(data: ResultData[]): Disparity { - // Filter out items with subgroup equal to "White (NH)" - const filteredData = data.filter((item) => item.subgroup !== 'White (NH)') - - const disparities = filteredData.map((item) => { - const { fips_name, subgroup, ...rest } = item - const [pctShareKey, measure] = getKeyBySubstring(rest, 'pct_share') - const [populationPctKey] = getKeyBySubstring(rest, 'population_pct') - const outcomeShare = Math.round(rest[pctShareKey]) - const populationShare = Math.round(rest[populationPctKey]) - const ratio = Math.round(outcomeShare / populationShare) - - const disparity: Disparity = { - location: fips_name, - subgroup, - disparity: ratio - 1, - measure, - outcomeShare, - populationShare, - ratio, - } - - return disparity - }) - - // Return the object with the highest disparity among the valid disparities - return disparities.reduce((max, curr) => - curr.disparity > max.disparity ? curr : max, - ) -} - -export function extractRelevantData( - dataset: Dataset, - metricIds: MetricId[], -): ResultData { - const { fips_name, race_and_ethnicity, age, sex, ...rest } = dataset - const result: ResultData = { fips_name } - - result.subgroup = race_and_ethnicity || age || sex - - metricIds.forEach((metricId) => { - result[metricId] = rest[metricId] - }) - - return result -} - -export async function checkRateLimitStatus(): Promise { - const baseApiUrl = import.meta.env.VITE_BASE_API_URL - const dataServerUrl = baseApiUrl - ? `${baseApiUrl}${RATE_LIMIT_ENDPOINT}` - : RATE_LIMIT_ENDPOINT - try { - const response = await fetch(dataServerUrl) - - if (!response.ok) { - console.error('Failed to check rate limit status') - return false - } - - const data = await response.json() - return data.rateLimitReached - } catch (error) { - console.error('Error checking rate limit status:', error) - return false - } -} diff --git a/frontend/src/cards/ui/InsightCard.tsx b/frontend/src/cards/ui/InsightCard.tsx new file mode 100644 index 0000000000..7ed641f785 --- /dev/null +++ b/frontend/src/cards/ui/InsightCard.tsx @@ -0,0 +1,39 @@ +import type React from 'react' +import type { + MetricConfig, + MetricId, +} from '../../data/config/MetricConfigTypes' +import type { DemographicType } from '../../data/query/Breakdowns' +import type { MetricQueryResponse } from '../../data/query/MetricQuery' +import type { Fips } from '../../data/utils/Fips' +import type { ScrollableHashId } from '../../utils/hooks/useStepObserver' + +export type InsightCardProps = { + demographicType: DemographicType + metricIds: MetricId[] + queryResponses: MetricQueryResponse[] + shareConfig: MetricConfig + hashId: ScrollableHashId + fips?: Fips + insight: string + isGeneratingInsight: boolean +} + +const InsightCard: React.FC = ({ + insight, + isGeneratingInsight, +}) => { + const showPanel = isGeneratingInsight || !!insight + + if (!showPanel) return null + + return ( +

+ {isGeneratingInsight + ? 'Analyzing health equity data with AI...' + : insight} +

+ ) +} + +export default InsightCard diff --git a/frontend/src/cards/ui/InsightDisplay.tsx b/frontend/src/cards/ui/InsightDisplay.tsx deleted file mode 100644 index 9015e2285d..0000000000 --- a/frontend/src/cards/ui/InsightDisplay.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { DeleteForever, TipsAndUpdatesOutlined } from '@mui/icons-material' -import CircularProgress from '@mui/material/CircularProgress' -import IconButton from '@mui/material/IconButton' -import type React from 'react' -import { useEffect, useState } from 'react' -import type { - MetricConfig, - MetricId, -} from '../../data/config/MetricConfigTypes' -import type { DemographicType } from '../../data/query/Breakdowns' -import type { MetricQueryResponse } from '../../data/query/MetricQuery' -import { splitIntoKnownsAndUnknowns } from '../../data/utils/datasetutils' -import { SHOW_INSIGHT_GENERATION } from '../../featureFlags' -import { generateInsight } from '../generateInsights' -import { checkRateLimitStatus } from '../generateInsightsUtils' - -type InsightDisplayProps = { - demographicType: DemographicType - metricIds: MetricId[] - queryResponses: MetricQueryResponse[] - shareConfig: MetricConfig -} - -const InsightDisplay: React.FC = ({ - queryResponses, - shareConfig, - demographicType, - metricIds, -}) => { - const [insight, setInsight] = useState('') - const [isGeneratingInsight, setIsGeneratingInsight] = useState(false) - const [rateLimitReached, setRateLimitReached] = useState(false) - - const queryResponse = queryResponses[0] - const validData = queryResponse.getValidRowsForField(shareConfig.metricId) - const [knownData] = splitIntoKnownsAndUnknowns(validData, demographicType) - - useEffect(() => { - async function checkLimit() { - if (SHOW_INSIGHT_GENERATION) { - const isLimited = await checkRateLimitStatus() - setRateLimitReached(isLimited) - } - } - - checkLimit() - }, []) - - const handleGenerateInsight = async () => { - if ( - !SHOW_INSIGHT_GENERATION || - !knownData.length || - !metricIds.length || - rateLimitReached - ) - return - - setIsGeneratingInsight(true) - try { - const newInsight = await generateInsight({ knownData, metricIds }) - setInsight(newInsight) - } finally { - setIsGeneratingInsight(false) - } - } - - const handleClearInsight = () => setInsight('') - - const showInsightButton = SHOW_INSIGHT_GENERATION && !rateLimitReached - - return ( - <> - {showInsightButton && ( - - {isGeneratingInsight ? ( - - ) : insight ? ( - - ) : ( - - )} - - )} -

- {isGeneratingInsight ? 'Generating insight...' : insight} -

- - ) -} - -export default InsightDisplay diff --git a/frontend/src/pages/ExploreData/InsightReportCard.tsx b/frontend/src/pages/ExploreData/InsightReportCard.tsx new file mode 100644 index 0000000000..b44aac9182 --- /dev/null +++ b/frontend/src/pages/ExploreData/InsightReportCard.tsx @@ -0,0 +1,194 @@ +import { AutoAwesome, Info, LocationOn, People } from '@mui/icons-material' +import { CircularProgress, Divider } from '@mui/material' +import { useAtomValue } from 'jotai' +import type React from 'react' +import { useEffect, useState } from 'react' +import HetCloseButton from '../../styles/HetComponents/HetCloseButton' +import { + generateReportInsight, + type ReportInsightSections, +} from '../../utils/generateReportInsight' +import { useParamState } from '../../utils/hooks/useParamState' +import type { MadLibId } from '../../utils/MadLibs' +import { + selectedDataTypeConfig1Atom, + selectedDemographicTypeAtom, + selectedFipsAtom, +} from '../../utils/sharedSettingsState' +import { REPORT_INSIGHT_PARAM_KEY } from '../../utils/urlutils' + +interface InsightReportCardProps { + setTrackerMode?: React.Dispatch> + headerScrollMargin?: number +} + +type SectionConfig = { + key: keyof ReportInsightSections + label: string + icon: React.ReactNode +} + +const SECTIONS: SectionConfig[] = [ + { + key: 'keyFindings', + label: 'Key Findings', + icon: , + }, + { + key: 'locationComparison', + label: 'Location Comparison', + icon: , + }, + { + key: 'demographicInsights', + label: 'Demographic Insights', + icon: , + }, + { + key: 'whatThisMeans', + label: 'What This Means', + icon: , + }, +] + +export default function InsightReportCard(props: InsightReportCardProps) { + const [, setIsOpen] = useParamState(REPORT_INSIGHT_PARAM_KEY) + + const dataTypeConfig = useAtomValue(selectedDataTypeConfig1Atom) + const fips = useAtomValue(selectedFipsAtom) + const demographicType = useAtomValue(selectedDemographicTypeAtom) + + const [sections, setSections] = useState(null) + const [isGenerating, setIsGenerating] = useState(false) + const [rateLimitReached, setRateLimitReached] = useState(false) + const [error, setError] = useState(null) + + // Clear sections whenever topic, fips, or demographicType changes + useEffect(() => { + setSections(null) + setError(null) + setRateLimitReached(false) + }, [dataTypeConfig?.dataTypeId, fips?.code, demographicType]) + + // Auto-generate on mount + useEffect(() => { + void handleGenerate() + }, []) + + async function handleGenerate() { + if (!dataTypeConfig || !fips || !demographicType) return + setIsGenerating(true) + setError(null) + try { + const result = await generateReportInsight( + dataTypeConfig, + demographicType, + fips, + ) + if (result.rateLimited) { + setRateLimitReached(true) + } else if (result.error || !result.sections) { + setError('Unable to generate insight. Please try again.') + } else { + setSections(result.sections) + } + } finally { + setIsGenerating(false) + } + } + + const handleRegenerate = () => { + setSections(null) + void handleGenerate() + } + + const handleClose = () => { + setIsOpen(false) + setSections(null) + setError(null) + setRateLimitReached(false) + props.setTrackerMode?.('disparity') + } + + const topOffset = props.headerScrollMargin ?? 0 + + return ( +
+
+ {/* Header */} +
+ + + AI Report Summary + + +
+ + + + {/* Loading */} + {isGenerating && ( +
+ +

+ Synthesizing data across all charts with AI... +

+
+ )} + + {/* Rate limited */} + {rateLimitReached && !isGenerating && ( +

+ Too many requests. Please wait a moment and try again. +

+ )} + + {/* Error */} + {error && !isGenerating && ( +

{error}

+ )} + + {/* Sections */} + {sections && !isGenerating && ( +
+ {SECTIONS.map(({ key, label, icon }) => ( +
+ + {icon} + {label} + +

+ {sections[key]} +

+
+ ))} + + +
+ )} + + + +

+ AI-generated synthesis based on report context. Always verify findings + with the source data shown in the charts above. +

+
+
+ ) +} diff --git a/frontend/src/pages/ExploreData/InsightReportModal.tsx b/frontend/src/pages/ExploreData/InsightReportModal.tsx new file mode 100644 index 0000000000..b465a2fad3 --- /dev/null +++ b/frontend/src/pages/ExploreData/InsightReportModal.tsx @@ -0,0 +1,189 @@ +import { AutoAwesome, Info, LocationOn, People } from '@mui/icons-material' +import { + CircularProgress, + Dialog, + DialogContent, + DialogTitle, +} from '@mui/material' +import { useAtomValue } from 'jotai' +import type React from 'react' +import { useEffect, useState } from 'react' +import HetCloseButton from '../../styles/HetComponents/HetCloseButton' +import { + generateReportInsight, + type ReportInsightSections, +} from '../../utils/generateReportInsight' +import { useParamState } from '../../utils/hooks/useParamState' +import { + selectedDataTypeConfig1Atom, + selectedDemographicTypeAtom, + selectedFipsAtom, +} from '../../utils/sharedSettingsState' +import { REPORT_INSIGHT_PARAM_KEY } from '../../utils/urlutils' + +type SectionConfig = { + key: keyof ReportInsightSections + label: string + icon: React.ReactNode +} + +const SECTIONS: SectionConfig[] = [ + { + key: 'keyFindings', + label: 'Key Findings', + icon: , + }, + { + key: 'locationComparison', + label: 'Location Comparison', + icon: , + }, + { + key: 'demographicInsights', + label: 'Demographic Insights', + icon: , + }, + { + key: 'whatThisMeans', + label: 'What This Means', + icon: , + }, +] + +export default function AIInsightModal() { + const [modalIsOpen, setModalIsOpen] = useParamState(REPORT_INSIGHT_PARAM_KEY) + + const dataTypeConfig = useAtomValue(selectedDataTypeConfig1Atom) + const fips = useAtomValue(selectedFipsAtom) + const demographicType = useAtomValue(selectedDemographicTypeAtom) + + const [sections, setSections] = useState(null) + const [isGenerating, setIsGenerating] = useState(false) + const [rateLimitReached, setRateLimitReached] = useState(false) + const [error, setError] = useState(null) + + // Clear sections whenever topic, fips, or demographicType changes so stale + // content never persists when the user navigates to a new report + useEffect(() => { + setSections(null) + setError(null) + setRateLimitReached(false) + }, [dataTypeConfig?.dataTypeId, fips?.code, demographicType]) + + // Auto-generate when modal opens + useEffect(() => { + if (modalIsOpen && !sections && !isGenerating) { + void handleGenerate() + } + }, [modalIsOpen]) + + async function handleGenerate() { + if (!dataTypeConfig || !fips || !demographicType) return + setIsGenerating(true) + setError(null) + try { + const result = await generateReportInsight( + dataTypeConfig, + demographicType, + fips, + ) + if (result.rateLimited) { + setRateLimitReached(true) + } else if (result.error || !result.sections) { + setError('Unable to generate insight. Please try again.') + } else { + setSections(result.sections) + } + } finally { + setIsGenerating(false) + } + } + + const handleRegenerate = () => { + setSections(null) + void handleGenerate() + } + + // Clear sections on close so reopening always starts fresh + const handleClose = () => { + setModalIsOpen(false) + setSections(null) + setError(null) + setRateLimitReached(false) + } + + return ( + + + + + AI Report Summary + + + + + + {/* Loading */} + {isGenerating && ( +
+ +

+ Synthesizing data across all charts with AI... +

+
+ )} + + {/* Rate limited */} + {rateLimitReached && !isGenerating && ( +

+ Too many requests. Please wait a moment and try again. +

+ )} + + {/* Error */} + {error && !isGenerating && ( +

{error}

+ )} + + {/* Sections */} + {sections && !isGenerating && ( +
+ {SECTIONS.map(({ key, label, icon }) => ( +
+ + {icon} + {label} + +

+ {sections[key]} +

+
+ ))} + + +
+ )} +
+ + + AI-generated synthesis based on report context. Always verify findings + with the source data shown in the charts above. + +
+ ) +} diff --git a/frontend/src/pages/ui/InsightReport.tsx b/frontend/src/pages/ui/InsightReport.tsx new file mode 100644 index 0000000000..bb7604fc30 --- /dev/null +++ b/frontend/src/pages/ui/InsightReport.tsx @@ -0,0 +1,19 @@ +import { SHOW_INSIGHT_GENERATION } from '../../featureFlags' +import type { MadLibId } from '../../utils/MadLibs' +import InsightReportModalButton from './InsightReportModalButton' + +interface InsightReportProps { + setTrackerMode: React.Dispatch> +} + +export default function InsightReport(props: InsightReportProps) { + if (!SHOW_INSIGHT_GENERATION) return null + + return ( +
+ props.setTrackerMode('comparegeos')} + /> +
+ ) +} diff --git a/frontend/src/pages/ui/InsightReportModalButton.tsx b/frontend/src/pages/ui/InsightReportModalButton.tsx new file mode 100644 index 0000000000..65714f7612 --- /dev/null +++ b/frontend/src/pages/ui/InsightReportModalButton.tsx @@ -0,0 +1,33 @@ +import { AutoAwesome } from '@mui/icons-material' +import { Button } from '@mui/material' +import { useParamState } from '../../utils/hooks/useParamState' +import { REPORT_INSIGHT_PARAM_KEY } from '../../utils/urlutils' + +interface InsightReportModalButtonProps { + onInsightClick?: () => void +} + +export default function ReportInsightModalButton( + props: InsightReportModalButtonProps, +) { + const [, setReportInsightModalIsOpen] = useParamState( + REPORT_INSIGHT_PARAM_KEY, + false, + ) + + const handleClick = () => { + setReportInsightModalIsOpen(true) + props.onInsightClick?.() + } + + return ( + + ) +} diff --git a/frontend/src/reports/CompareReport.tsx b/frontend/src/reports/CompareReport.tsx index b5eef795b7..cef2abd6cf 100644 --- a/frontend/src/reports/CompareReport.tsx +++ b/frontend/src/reports/CompareReport.tsx @@ -22,7 +22,8 @@ import { } from '../data/query/Breakdowns' import { AGE, RACE } from '../data/utils/Constants' import type { Fips } from '../data/utils/Fips' -import { SHOW_CORRELATION_CARD } from '../featureFlags' +import { SHOW_CORRELATION_CARD, SHOW_INSIGHT_GENERATION } from '../featureFlags' +import InsightReportCard from '../pages/ExploreData/InsightReportCard' import Sidebar from '../pages/ui/Sidebar' import { useParamState } from '../utils/hooks/useParamState' import type { ScrollableHashId } from '../utils/hooks/useStepObserver' @@ -37,6 +38,7 @@ import { DEMOGRAPHIC_PARAM, getParameter, psSubscribe, + REPORT_INSIGHT_PARAM_KEY, swapOldDatatypeParams, } from '../utils/urlutils' import { reportProviderSteps } from './ReportProviderSteps' @@ -77,6 +79,8 @@ export default function CompareReport(props: CompareReportProps) { defaultDemo, ) + const [insightIsOpen] = useParamState(REPORT_INSIGHT_PARAM_KEY) + const [dataTypeConfig1, setDtConfig1] = useAtom(selectedDataTypeConfig1Atom) const [dataTypeConfig2, setDtConfig2] = useAtom(selectedDataTypeConfig2Atom) @@ -188,12 +192,16 @@ export default function CompareReport(props: CompareReportProps) { const showCorrelationCard = SHOW_CORRELATION_CARD && props.trackerMode === 'comparevars' + const insightMode = Boolean(SHOW_INSIGHT_GENERATION && insightIsOpen) + return ( <> {`${browserTitle} - Health Equity Tracker`}
{/* CARDS COLUMN */} -
+
{/* Mode selectors here on small/medium, in sidebar instead for larger screens */}
+ {/* INSIGHT CARD COLUMN - shown when insight is open */} + {SHOW_INSIGHT_GENERATION && insightIsOpen && dataTypeConfig1 && ( +
+ +
+ )} {/* SIDEBAR COLUMN - DESKTOP ONLY */} {props.reportStepHashIds && (
diff --git a/frontend/src/reports/Report.tsx b/frontend/src/reports/Report.tsx index 282b7fb60e..fc5ebd85b1 100644 --- a/frontend/src/reports/Report.tsx +++ b/frontend/src/reports/Report.tsx @@ -18,12 +18,17 @@ import { } from '../data/query/Breakdowns' import { AGE, RACE } from '../data/utils/Constants' import type { Fips } from '../data/utils/Fips' +import InsightReport from '../pages/ui/InsightReport' import Sidebar from '../pages/ui/Sidebar' import HetLazyLoader from '../styles/HetComponents/HetLazyLoader' import { useParamState } from '../utils/hooks/useParamState' import type { ScrollableHashId } from '../utils/hooks/useStepObserver' import type { MadLibId } from '../utils/MadLibs' -import { selectedDataTypeConfig1Atom } from '../utils/sharedSettingsState' +import { + selectedDataTypeConfig1Atom, + selectedDemographicTypeAtom, + selectedFipsAtom, +} from '../utils/sharedSettingsState' import { DATA_TYPE_1_PARAM, DEMOGRAPHIC_PARAM, @@ -69,6 +74,8 @@ export function Report(props: ReportProps) { const [dataTypeConfig, setDataTypeConfig] = useAtom( selectedDataTypeConfig1Atom, ) + const [, setSelectedFips] = useAtom(selectedFipsAtom) + const [, setSelectedDemographicType] = useAtom(selectedDemographicTypeAtom) const { enabledDemographicOptionsMap, disabledDemographicOptions } = getAllDemographicOptions(dataTypeConfig, props.fips) @@ -99,13 +106,15 @@ export function Report(props: ReportProps) { } const psHandler = psSubscribe(readParams, 'vardisp') readParams() + setSelectedFips(props.fips) + setSelectedDemographicType(demographicType) return () => { if (psHandler) { psHandler.unsubscribe() } } - }, [props.dropdownVarId, demographicType]) + }, [props.dropdownVarId, demographicType, props.fips]) // when variable config changes (new data type), re-calc available card steps TableOfContents useEffect(() => { @@ -322,6 +331,9 @@ export function Report(props: ReportProps) {
+ {dataTypeConfig && ( + + )}
)}
-
- {dataTypeConfig2 && ( - <> - {props.createCard( - dataTypeConfig2, - props.fips2, - props.updateFips2 ?? unusedFipsCallback, - props.dropdownVarId2, - /* isCompareCard */ true, - )} - - )} -
+ {!props.hideSecondCard && ( +
+ {dataTypeConfig2 && ( + <> + {props.createCard( + dataTypeConfig2, + props.fips2, + props.updateFips2 ?? unusedFipsCallback, + props.dropdownVarId2, + /* isCompareCard */ true, + )} + + )} +
+ )}
) } diff --git a/frontend/src/utils/generateCardInsight.ts b/frontend/src/utils/generateCardInsight.ts new file mode 100644 index 0000000000..2541b2b92d --- /dev/null +++ b/frontend/src/utils/generateCardInsight.ts @@ -0,0 +1,165 @@ +import type { MetricId } from '../data/config/MetricConfigTypes' +import type { Fips } from '../data/utils/Fips' +import { SHOW_INSIGHT_GENERATION } from '../featureFlags' +import type { ChartData } from '../reports/Report' +import type { ScrollableHashId } from './hooks/useStepObserver' + +const API_ENDPOINT = '/fetch-ai-insight' +const ERROR_GENERATING_INSIGHT = 'Error generating insight' + +export type Dataset = Record + +export type InsightResult = { + content: string + rateLimited: boolean +} + +async function fetchAIInsight(prompt: string): Promise { + const baseApiUrl = import.meta.env.VITE_BASE_API_URL + const dataServerUrl = baseApiUrl + ? `${baseApiUrl}${API_ENDPOINT}` + : API_ENDPOINT + + if (!SHOW_INSIGHT_GENERATION) { + return { content: '', rateLimited: false } + } + + try { + const dataResponse = await fetch(dataServerUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }) + + if (dataResponse.status === 429) { + return { content: '', rateLimited: true } + } + + if (!dataResponse.ok) { + throw new Error(`Failed to fetch AI insight: ${dataResponse.statusText}`) + } + + const insight = await dataResponse.json() + if (!insight.content) { + throw new Error('No content returned from AI service') + } + + return { content: insight.content.trim(), rateLimited: false } + } catch (error) { + console.error('Error generating insight:', error) + return { content: ERROR_GENERATING_INSIGHT, rateLimited: false } + } +} + +function extractMetadata(data: Dataset[]): { + topic: string + demographic: string +} { + const firstDataPoint = data[0] || {} + + let demographic = 'overall population' + if (firstDataPoint.subgroup) { + const subgroup = firstDataPoint.subgroup + if (subgroup.includes('(NH)') || subgroup.includes('Latino')) { + demographic = 'race and ethnicity' + } else if (!isNaN(Number(subgroup)) || subgroup.includes('-')) { + demographic = 'age group' + } else if (subgroup === 'Male' || subgroup === 'Female') { + demographic = 'sex' + } + } + + // Extract topic from the data keys + const dataKeys = Object.keys(firstDataPoint).filter( + (k) => k !== 'fips_name' && k !== 'subgroup' && k !== 'time_period', + ) + const firstMetricKey = dataKeys[0] || '' + const topic = firstMetricKey + .replace( + /_pct_share|_population_pct|_per_100k|_rate|_estimated_total|_population/gi, + '', + ) + .replace(/_/g, ' ') + .trim() + + return { topic, demographic } +} + +function generateInsightPrompt( + topic: string, + location: string, + demographic: string, + formattedData: string, + hashId: ScrollableHashId, +): string { + return `Analyze health data about ${topic} in ${location} for ${demographic} with this data: ${formattedData}. + +Write a single, clear paragraph (2-3 sentences) that identifies the most significant disparity or pattern and makes the real-world impact clear using plain language for our ${hashId} chart.` +} + +function mapRelevantData( + dataArray: Dataset[], + metricIds: MetricId[], +): Dataset[] { + return dataArray.map((dataset) => { + const { fips_name, race_and_ethnicity, age, sex, time_period, ...rest } = + dataset + const result: Dataset = { fips_name } + + // Add demographic field + const subgroup = race_and_ethnicity || age || sex + if (subgroup) { + result.subgroup = subgroup + } + + // Preserve time_period if it exists + if (time_period !== undefined) { + result.time_period = time_period + } + + // Add metric values + metricIds.forEach((metricId) => { + result[metricId] = rest[metricId] + }) + + return result + }) +} + +export async function generateCardInsight( + chartMetrics: ChartData, + hashId: ScrollableHashId, + fips?: Fips, +): Promise { + if (!SHOW_INSIGHT_GENERATION) { + return { content: '', rateLimited: false } + } + + try { + const { knownData, metricIds } = chartMetrics + + if (!knownData || knownData.length === 0) { + return { + content: 'No data available to generate insights.', + rateLimited: false, + } + } + + const processedData = mapRelevantData(knownData, metricIds) + const { topic, demographic } = extractMetadata(processedData) + const location = fips?.getDisplayName() || '' + const formattedData = JSON.stringify(processedData, null, 2) + const prompt = generateInsightPrompt( + topic, + location, + demographic, + formattedData, + hashId, + ) + + return await fetchAIInsight(prompt) + } catch (error) { + console.error(ERROR_GENERATING_INSIGHT, error) + return { content: ERROR_GENERATING_INSIGHT, rateLimited: false } + } +} diff --git a/frontend/src/utils/generateReportInsight.ts b/frontend/src/utils/generateReportInsight.ts new file mode 100644 index 0000000000..94c694b8ac --- /dev/null +++ b/frontend/src/utils/generateReportInsight.ts @@ -0,0 +1,158 @@ +import type { DataTypeConfig } from '../data/config/MetricConfigTypes' +import { + DEMOGRAPHIC_DISPLAY_TYPES_LOWER_CASE, + type DemographicType, +} from '../data/query/Breakdowns' +import type { Fips } from '../data/utils/Fips' +import { SHOW_INSIGHT_GENERATION } from '../featureFlags' + +const API_ENDPOINT = '/fetch-ai-insight' +const ERROR_GENERATING_INSIGHT = 'Error generating report insight' + +export type InsightResult = { + content: string + rateLimited: boolean +} + +export type ReportInsightSections = { + keyFindings: string + locationComparison: string + demographicInsights: string + whatThisMeans: string +} + +export type ReportInsightResult = { + sections: ReportInsightSections | null + rateLimited: boolean + error?: string +} + +const EMPTY_RESULT: ReportInsightResult = { + sections: null, + rateLimited: false, +} + +async function fetchAIInsight(prompt: string): Promise { + const baseApiUrl = import.meta.env.VITE_BASE_API_URL + const dataServerUrl = baseApiUrl + ? `${baseApiUrl}${API_ENDPOINT}` + : API_ENDPOINT + + if (!SHOW_INSIGHT_GENERATION) { + return { content: '', rateLimited: false } + } + + try { + const dataResponse = await fetch(dataServerUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }) + + if (dataResponse.status === 429) { + return { content: '', rateLimited: true } + } + + if (!dataResponse.ok) { + throw new Error(`Failed to fetch AI insight: ${dataResponse.statusText}`) + } + + const insight = await dataResponse.json() + if (!insight.content) { + throw new Error('No content returned from AI service') + } + + return { content: insight.content.trim(), rateLimited: false } + } catch (error) { + console.error('Error generating report insight:', error) + return { content: ERROR_GENERATING_INSIGHT, rateLimited: false } + } +} + +function generateReportInsightPrompt( + topic: string, + location: string, + demographicTypeString: string, +): string { + return `You are a public health analyst reviewing a full report page about "${topic}" in ${location}, broken down by ${demographicTypeString}. + +The page contains multiple charts: a rate map, rates over time, a rate bar chart, an unknowns map, inequities over time, and a population vs distribution chart. + +Respond ONLY with a valid JSON object — no markdown, no backticks, no explanation outside the JSON. Use this exact structure: + +{ + "keyFindings": "2-3 sentences identifying the most significant disparity across all charts, leading with the most striking number or pattern.", + "locationComparison": "2-3 sentences describing how disparities vary geographically — which states or regions show the most extreme gaps and what that suggests about structural vs localized factors.", + "demographicInsights": "2-3 sentences naming the most disproportionately affected group, quantifying the gap between highest and lowest rates, and describing the population share vs case share imbalance.", + "whatThisMeans": "2-3 sentences translating the data into real-world human impact — what does this mean for people living in these communities, in plain language." +}` +} + +function parseSections(raw: string): ReportInsightSections | null { + try { + const clean = raw.replace(/```json|```/g, '').trim() + const parsed = JSON.parse(clean) + + const required: (keyof ReportInsightSections)[] = [ + 'keyFindings', + 'locationComparison', + 'demographicInsights', + 'whatThisMeans', + ] + + for (const key of required) { + if (typeof parsed[key] !== 'string') return null + } + + return parsed as ReportInsightSections + } catch { + console.error('Failed to parse report insight JSON') + return null + } +} + +export async function generateReportInsight( + dataTypeConfig: DataTypeConfig, + demographicType: DemographicType, + fips: Fips, +): Promise { + if (!SHOW_INSIGHT_GENERATION) { + return EMPTY_RESULT + } + + try { + const topic = dataTypeConfig.fullDisplayName + const location = fips.getSentenceDisplayName() + const demographicTypeString = + DEMOGRAPHIC_DISPLAY_TYPES_LOWER_CASE[demographicType] ?? 'demographic' + + const prompt = generateReportInsightPrompt( + topic, + location, + demographicTypeString, + ) + const result = await fetchAIInsight(prompt) + + if (result.rateLimited) { + return { sections: null, rateLimited: true } + } + + if (result.content === ERROR_GENERATING_INSIGHT) { + return { + sections: null, + rateLimited: false, + error: ERROR_GENERATING_INSIGHT, + } + } + + const sections = parseSections(result.content) + return { sections, rateLimited: false } + } catch (error) { + console.error(ERROR_GENERATING_INSIGHT, error) + return { + sections: null, + rateLimited: false, + error: ERROR_GENERATING_INSIGHT, + } + } +} diff --git a/frontend/src/utils/sharedSettingsState.ts b/frontend/src/utils/sharedSettingsState.ts index 539caf4d5f..b37c65bebc 100644 --- a/frontend/src/utils/sharedSettingsState.ts +++ b/frontend/src/utils/sharedSettingsState.ts @@ -1,9 +1,14 @@ import { atom } from 'jotai' import { atomWithLocation } from 'jotai-location' import type { DataTypeConfig } from '../data/config/MetricConfigTypes' +import type { DemographicType } from '../data/query/Breakdowns' +import type { Fips } from '../data/utils/Fips' export const selectedDataTypeConfig1Atom = atom(null) export const selectedDataTypeConfig2Atom = atom(null) +export const selectedFipsAtom = atom(null) +export const selectedDemographicTypeAtom = atom(null) + /* SHARED SYNCED URL PARAMS STATE */ export const locationAtom = atomWithLocation() diff --git a/frontend/src/utils/urlutils.tsx b/frontend/src/utils/urlutils.tsx index 0e185aca6c..33757fc79b 100644 --- a/frontend/src/utils/urlutils.tsx +++ b/frontend/src/utils/urlutils.tsx @@ -19,6 +19,7 @@ import { WHAT_IS_HEALTH_EQUITY_PAGE_LINK, } from './internalRoutes' import type { PhraseSelections } from './MadLibs' +export const REPORT_INSIGHT_PARAM_KEY = 'report-insight' // OLDER HANDLING PARAMS diff --git a/frontend_server/Dockerfile b/frontend_server/Dockerfile index 1df0fd7d0d..5b622c62f4 100644 --- a/frontend_server/Dockerfile +++ b/frontend_server/Dockerfile @@ -21,8 +21,8 @@ ARG DEPLOY_CONTEXT ENV DEPLOY_CONTEXT=$DEPLOY_CONTEXT ARG AHR_API_KEY ENV AHR_API_KEY=$AHR_API_KEY -ARG OPENAI_API_KEY -ENV OPENAI_API_KEY=$OPENAI_API_KEY +ARG ANTHROPIC_API_KEY +ENV ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY WORKDIR /usr/src/app diff --git a/frontend_server/server.js b/frontend_server/server.js index 5a99b86f13..a90950cb95 100644 --- a/frontend_server/server.js +++ b/frontend_server/server.js @@ -1,12 +1,10 @@ import compression from 'compression' -import path, { dirname } from 'node:path' -import { fileURLToPath } from 'node:url' -// TODO: change over to use ESModules with import() instead of require() ? import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' +import path, { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' const buildDir = process.env['BUILD_DIR'] || 'build' -let RATE_LIMIT_REACHED = false console.info(`Build directory: ${buildDir}`) export function assertEnvVar(name) { @@ -14,9 +12,7 @@ export function assertEnvVar(name) { console.info(`Environment variable ${name}: ${value}`) if (value === 'NULL') return '' if (!value) { - throw new Error( - `Invalid environment variable. Name: ${name}, value: ${value}`, - ) + throw new Error(`Invalid environment variable. Name: ${name}, value: ${value}`) } return value } @@ -25,15 +21,11 @@ export function getBooleanEnvVar(name) { const value = process.env[name] console.info(`Environment variable ${name}: ${value}`) if (value && value !== 'true' && value !== 'false') { - throw new Error( - `Invalid boolean environment variable. Name: ${name}, value: ${value}`, - ) + throw new Error(`Invalid boolean environment variable. Name: ${name}, value: ${value}`) } return value === 'true' } -// TODO it would be nice to extract PORT and HOST to environment variables -// because it's good practice not to hard-code this kind of configuration. const PORT = 8080 const HOST = '0.0.0.0' const app = express() @@ -41,41 +33,24 @@ const app = express() app.use(express.json()) app.use(compression()) -// CORS middleware app.use((req, res, next) => { - // Allow all origins for development or use '*' in non-production environments res.setHeader('Access-Control-Allow-Origin', '*') - - // Set standard CORS headers res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') - - // Handle preflight requests if (req.method === 'OPTIONS') { return res.status(204).end() } - next() }) -// Add Authorization header for all requests that are proxied to the data server. -// TODO: The token can be cached and only refreshed when needed app.use('/api', (req, res, next) => { if (assertEnvVar('NODE_ENV') === 'production') { - // Set up metadata server request - // See https://cloud.google.com/compute/docs/instances/verifying-instance-identity#request_signature const metadataServerTokenURL = assertEnvVar('METADATA_SERVER_TOKEN_URL') const targetUrl = assertEnvVar('DATA_SERVER_URL') const fetchUrl = metadataServerTokenURL + targetUrl - const options = { - headers: { - 'Metadata-Flavor': 'Google', - }, - } - fetch(fetchUrl, options) + fetch(fetchUrl, { headers: { 'Metadata-Flavor': 'Google' } }) .then((res) => res.text()) .then((token) => { - // Set the bearer token temporarily to Authorization_DataServer header. req.headers['Authorization_DataServer'] = `bearer ${token}` next() }) @@ -85,32 +60,16 @@ app.use('/api', (req, res, next) => { } }) -// TODO check if these are all the right proxy options. For example, there's a -// "secure" option that makes it check SSL certificates. I don't think we need -// it but I can't find good documentation. -// TODO add logging if there's an error in the request. const apiProxyOptions = { target: assertEnvVar('DATA_SERVER_URL'), - changeOrigin: true, // needed for virtual hosted sites + changeOrigin: true, pathRewrite: { '^/api': '' }, onProxyReq: (proxyReq) => { - proxyReq.setHeader( - 'Authorization', - proxyReq.getHeader('Authorization_DataServer'), - ) + proxyReq.setHeader('Authorization', proxyReq.getHeader('Authorization_DataServer')) proxyReq.removeHeader('Authorization_DataServer') }, } -const apiProxy = createProxyMiddleware(apiProxyOptions) -app.use('/api', apiProxy) - -app.use(compression()) - -app.get('/rate-limit-status', (req, res) => { - res.json({ - rateLimitReached: RATE_LIMIT_REACHED, - }) -}) +app.use('/api', createProxyMiddleware(apiProxyOptions)) const aiInsightCache = new Map() const CACHE_TTL_MS = 24 * 60 * 60 * 1000 @@ -121,71 +80,54 @@ app.post('/fetch-ai-insight', async (req, res) => { return res.status(400).json({ error: 'Missing prompt parameter' }) } - // Check if response is cached and not expired const now = Date.now() const cachedItem = aiInsightCache.get(prompt) - if (cachedItem && now - cachedItem.timestamp < CACHE_TTL_MS) { return res.json({ content: cachedItem.content }) } - const apiKey = assertEnvVar('OPENAI_API_KEY') + const apiKey = assertEnvVar('ANTHROPIC_API_KEY') try { - const aiResponse = await fetch( - 'https://api.openai.com/v1/chat/completions', - { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: 'gpt-4', - messages: [{ role: 'user', content: prompt }], - max_tokens: 500, - }), + const aiResponse = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', }, - ) + body: JSON.stringify({ + model: 'claude-sonnet-4-5-20250929', + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }], + }), + }) if (aiResponse.status === 429) { - RATE_LIMIT_REACHED = true return res.status(429).json({ error: 'Rate limit reached' }) } - RATE_LIMIT_REACHED = false - if (!aiResponse.ok) { throw new Error(`AI API Error: ${aiResponse.statusText}`) } const json = await aiResponse.json() - const content = json.choices?.[0]?.message?.content || 'No content returned' + const content = json.content?.[0]?.text || 'No content returned' const trimmedContent = content.trim() - // Store in cache with timestamp - aiInsightCache.set(prompt, { - content: trimmedContent, - timestamp: now, - }) - + aiInsightCache.set(prompt, { content: trimmedContent, timestamp: now }) res.json({ content: trimmedContent }) } catch (err) { console.error('Error fetching AI insight:', err) - res.status(500).json({ error: 'Failed to fetch AI insight' }) } }) -// Serve static files from the build directory. + const __dirname = dirname(fileURLToPath(import.meta.url)) app.use(express.static(path.join(__dirname, buildDir))) - -// Route all other paths to index.html. The "*" must be used otherwise -// client-side routing wil fail due to missing exact matches. For more info, see -// https://create-react-app.dev/docs/deployment/#serving-apps-with-client-side-routing app.get('/*', (req, res) => { res.sendFile(path.join(__dirname, buildDir, 'index.html')) }) app.listen(PORT, HOST) -console.info(`Running on http://${HOST}:${PORT}`) +console.info(`Running on http://${HOST}:${PORT}`) \ No newline at end of file diff --git a/run_gcs_to_bq/Dockerfile b/run_gcs_to_bq/Dockerfile index 8cda027b89..910ca21827 100644 --- a/run_gcs_to_bq/Dockerfile +++ b/run_gcs_to_bq/Dockerfile @@ -8,10 +8,11 @@ ENV PYTHONUNBUFFERED True # Define the build-time variable ARG AHR_API_KEY +ARG ANTHROPIC_API_KEY # Set the environment variable using the ARG value ENV AHR_API_KEY=$AHR_API_KEY - +ENV ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY # Copy local code to the container image. ENV APP_HOME /app WORKDIR $APP_HOME