diff --git a/src/layout/MarkdownFormatter.jsx b/src/layout/MarkdownFormatter.jsx index 5bb231558..30ee7b12b 100644 --- a/src/layout/MarkdownFormatter.jsx +++ b/src/layout/MarkdownFormatter.jsx @@ -164,7 +164,7 @@ export function PlotlyChartCode({ data, backgroundColor }) { ); } -export function MarkdownFormatter({ markdown, backgroundColor, dict }) { +export function MarkdownFormatter({ markdown, backgroundColor, dict, pSize }) { const displayCategory = useDisplayCategory(); const mobile = displayCategory === "mobile"; const renderers = { @@ -197,7 +197,7 @@ export function MarkdownFormatter({ markdown, backgroundColor, dict }) {

@@ -243,7 +243,7 @@ export function MarkdownFormatter({ markdown, backgroundColor, dict }) { paddingLeft: 20, marginBottom: 20, fontFamily: "Roboto Serif", - fontSize: mobile ? 16 : 18, + fontSize: pSize ? pSize : mobile ? 16 : 18, }} > {children} @@ -255,7 +255,7 @@ export function MarkdownFormatter({ markdown, backgroundColor, dict }) { paddingLeft: 20, marginBottom: 20, fontFamily: "Roboto Serif", - fontSize: mobile ? 16 : 18, + fontSize: pSize ? pSize : mobile ? 16 : 18, }} > {children} diff --git a/src/modals/HouseholdAIModal.jsx b/src/modals/HouseholdAIModal.jsx new file mode 100644 index 000000000..c17575ee7 --- /dev/null +++ b/src/modals/HouseholdAIModal.jsx @@ -0,0 +1,112 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Modal } from "antd"; +import { countryApiCall } from "../api/call"; +import useCountryId from "../hooks/useCountryId"; +import { MarkdownFormatter } from "../layout/MarkdownFormatter"; +import { useSearchParams } from "react-router-dom"; +import { COUNTRY_BASELINE_POLICIES } from "../data/countries"; + +/** + * Modal for displaying AI output + * @param {Object} props + * @param {Object} props.variableName The name of the variable + * @param {String} props.value The value of the variable + * @param {Boolean} props.isModalVisible Whether the modal is visible + * @param {Function} props.setIsModalVisible Function to set the visibility of the modal + * @returns {React.Component} The modal component + */ +export default function HouseholdAIModal(props) { + const { isModalVisible, setIsModalVisible, variableName } = props; + + const [analysis, setAnalysis] = useState(""); + const countryId = useCountryId(); + + // Check if variable has changed by its name, not the + // object itself; React will treat two objects with same keys + // and values as different if rendered separately + const prevVariableName = useRef(null); + + const [searchParams] = useSearchParams(); + const householdId = searchParams.get("household"); + // Currently not implemented for baseline/reform comparison pairs + const policyId = COUNTRY_BASELINE_POLICIES[countryId]; + + // Function to hide modal + const handleCancel = () => { + setIsModalVisible(false); + }; + + // Convert this and fetchTracer to async/await + const fetchAnalysis = useCallback(async () => { + const jsonObject = { + household_id: householdId, + policy_id: policyId, + variable: variableName, + }; + + const res = await countryApiCall( + countryId, + `/tracer-analysis`, + jsonObject, + "POST", + ); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + + let isComplete = false; + while (!isComplete) { + const { done, value } = await reader.read().catch((error) => { + console.error("Error reading response stream:", error); + }); + if (done) { + isComplete = true; + } + const chunks = decoder.decode(value, { stream: true }).split("\n"); + for (const chunk of chunks) { + if (chunk) { + const data = JSON.parse(chunk); + if (data.stream) { + setAnalysis((prevAnalysis) => prevAnalysis + data.stream); + } + } + } + } + }, [countryId, householdId, policyId, variableName]); + + useEffect(() => { + function resetModalData() { + prevVariableName.current = variableName; + } + + // If modal isn't shown, don't do anything + if (!isModalVisible) { + return; + } + + // If variable hasn't changed and we generated analysis, + // don't do anything (e.g., user clicked on same variable) + if (variableName === prevVariableName.current) { + return; + } + + fetchAnalysis(); + resetModalData(); + }, [isModalVisible, variableName, fetchAnalysis]); + + return ( + + Close + , + ]} + width="50%" + > + + + ); +} diff --git a/src/pages/household/output/NetIncomeBreakdown.jsx b/src/pages/household/output/NetIncomeBreakdown.jsx index 888b276ea..6252714f0 100644 --- a/src/pages/household/output/NetIncomeBreakdown.jsx +++ b/src/pages/household/output/NetIncomeBreakdown.jsx @@ -2,8 +2,9 @@ import { CaretDownFilled, CaretUpFilled, PlusCircleOutlined, + InfoCircleOutlined, } from "@ant-design/icons"; -import { Tooltip } from "antd"; +import { Tooltip, Button } from "antd"; import { useState } from "react"; import { getParameterAtInstant } from "../../../api/parameters"; import { @@ -14,6 +15,8 @@ import ResultsPanel from "../../../layout/ResultsPanel"; import style from "../../../style"; import useDisplayCategory from "../../../hooks/useDisplayCategory"; import { Helmet } from "react-helmet"; +import React from "react"; +import HouseholdAIModal from "../../../modals/HouseholdAIModal"; const UpArrow = () => ( 0 && childNodes.length > 0; + // State for modal visibility + const [isModalVisible, setIsModalVisible] = useState(false); + + // Function to show modal + const showModal = () => { + setIsModalVisible(true); + }; + if (childrenOnly) { return (

+ {expandable && ( )} + + {!expandable && !isInput && !householdReform && ( +
+ + +
+ )}
{expanded && ( @@ -310,6 +362,11 @@ function VariableArithmetic(props) { {childNodes} )} + ); } diff --git a/src/pages/policy/output/Analysis.jsx b/src/pages/policy/output/Analysis.jsx index 90298d38a..9a8195fac 100644 --- a/src/pages/policy/output/Analysis.jsx +++ b/src/pages/policy/output/Analysis.jsx @@ -6,9 +6,8 @@ import CodeBlock from "../../../layout/CodeBlock"; import colors from "../../../style/colors"; import { getParameterAtInstant } from "../../../api/parameters"; import { MarkdownFormatter } from "../../../layout/MarkdownFormatter"; -import { asyncApiCall, countryApiCall } from "../../../api/call"; +import { countryApiCall } from "../../../api/call"; import { getImpactReps } from "./ImpactTypes"; -import { promptContent } from "./promptContent"; export default function Analysis(props) { const { impact, policyLabel, metadata, policy, region, timePeriod } = props; @@ -48,39 +47,11 @@ export default function Analysis(props) { }; }, ); - // metadata.economy_options.region = [{name: "uk", label: "United Kingdom"}] - const regionKeyToLabel = metadata.economy_options.region.reduce( - (acc, { name, label }) => { - acc[name] = label; - return acc; - }, - {}, - ); const [audience, setAudience] = useState("Normal"); - const audienceDescriptions = { - ELI5: "Write this for a five-year-old who doesn't know anything about economics or policy. Explain fundamental concepts like taxes, poverty rates, and inequality as needed.", - Normal: - "Write this for a policy analyst who knows a bit about economics and policy.", - Wonk: "Write this for a policy analyst who knows a lot about economics and policy. Use acronyms and jargon if it makes the content more concise and informative.", - }; - - const prompt = - promptContent( - metadata, - selectedVersion, - timePeriod, - regionKeyToLabel, - impact, - policyLabel, - policy, - region, - relevantParameterBaselineValues, - relevantParameters, - ) + audienceDescriptions[audience]; - const [analysis, setAnalysis] = useState(""); const [loading, setLoading] = useState(false); const [hasClickedGenerate, setHasClickedGenerate] = useState(false); + const [prompt, setPrompt] = useState(""); const [showPrompt, setShowPrompt] = useState(false); const lines = prompt.split("\n"); @@ -129,56 +100,62 @@ export default function Analysis(props) { const displayCharts = (markdown) => markdown.replace( - /{{(.*?)}}/g, + /{(.*?)}/g, (match, impactType) => ``, ); - const onGenerate = () => { + const onGenerate = async () => { setHasClickedGenerate(true); setLoading(true); setAnalysis(""); // Reset analysis content - let fullAnalysis = ""; - countryApiCall(metadata.countryId, `/analysis`, { - prompt: prompt, - }) - .then((res) => res.json()) - .then((data) => { - return data.result.prompt_id; - }) - .then((promptId) => { - asyncApiCall( - `/${metadata.countryId}/analysis/${promptId}`, - null, - 9_000, - 4_000, - (data) => { - // We've got to wait ten seconds for the next part of the response to be ready, - // so let's add the response word-by-word with a small delay to make it seem typed. - const analysisFromCall = data.result.analysis; - // Start from the new bit (compare against fullAnalysis) - const newAnalysis = analysisFromCall.substring(fullAnalysis.length); - // Start from the - const analysisWords = newAnalysis.split(" "); - for (let i = 0; i < analysisWords.length; i++) { - setTimeout(() => { - setAnalysis((analysis) => - displayCharts(analysis + " " + analysisWords[i]).replaceAll( - " ", - " ", - ), - ); - }, 100 * i); - } - fullAnalysis = analysisFromCall; - }, - ).then((data) => { - setAnalysis( - displayCharts(data.result.analysis).replaceAll(" ", " "), - ); - setLoading(false); - }); + const jsonObject = { + currency: metadata.currency, + selected_version: selectedVersion, + time_period: timePeriod, + impact: impact, + policy_label: policyLabel, + policy: policy, + region: region, + relevant_parameter_baseline_values: relevantParameterBaselineValues, + relevant_parameters: relevantParameters, + audience: audience, + }; + + const res = await countryApiCall( + metadata.countryId, + `/simulation-analysis`, + jsonObject, + "POST", + ); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + + let isComplete = false; + while (!isComplete) { + const { done, value } = await reader.read().catch((error) => { + console.error("Error reading response stream:", error); }); + if (done) { + isComplete = true; + } + const chunks = decoder.decode(value, { stream: true }).split("\n"); + for (const chunk of chunks) { + if (chunk) { + const data = JSON.parse(chunk); + if (data.stream) { + setAnalysis((prevAnalysis) => prevAnalysis + data.stream); + } + if (data.prompt) { + setPrompt(data.prompt); + } + } + } + } + + setAnalysis((analysis) => displayCharts(analysis).replaceAll(" ", " ")); + setLoading(false); }; const buttonText = !hasClickedGenerate ? ( "Generate an analysis" diff --git a/src/pages/policy/output/promptContent.js b/src/pages/policy/output/promptContent.js index 05df3cc42..e69de29bb 100644 --- a/src/pages/policy/output/promptContent.js +++ b/src/pages/policy/output/promptContent.js @@ -1,108 +0,0 @@ -export const promptContent = ( - metadata, - selectedVersion, - timePeriod, - regionKeyToLabel, - impact, - policyLabel, - policy, - region, - relevantParameterBaselineValues, - relevantParameters, -) => { - const isEnhancedCPS = regionKeyToLabel[region].includes("enhanced CPS"); - - const policyDetails = `I'm using PolicyEngine, a free, open source tool to compute the impact of public policy. I'm writing up an economic analysis of a hypothetical tax-benefit policy reform. Please write the analysis for me using the details below, in their order. You should: - - * First explain each provision of the reform, noting that it's hypothetical and won't represents policy reforms for ${timePeriod} and ${ - regionKeyToLabel[region] - }. Explain how the parameters are changing from the baseline to the reform values using the given data. - ${ - isEnhancedCPS && - "* Explicitly mention that this analysis uses PolicyEngine's Enhanced CPS, constructed from the 2024 Current Population Survey and the 2015 IRS Public Use File, and calibrated to tax, benefit, income, and demographic aggregates." - } - * Round large numbers like: ${metadata.currency}3.1 billion, ${ - metadata.currency - }300 million, ${metadata.currency}106,000, ${metadata.currency}1.50 (never ${ - metadata.currency - }1.5). - * Round percentages to one decimal place. - * Avoid normative language like 'requires', 'should', 'must', and use quantitative statements over general adjectives and adverbs. If you don't know what something is, don't make it up. - * Avoid speculating about the intent of the policy or inferring any motives; only describe the observable effects and impacts of the policy. Refrain from using subjective language or making assumptions about the recipients and their needs. - * Use the active voice where possible; for example, write phrases where the reform is the subject, such as "the reform [or a description of the reform] reduces poverty by x%". - * Use ${ - metadata.countryId === "uk" ? "British" : "American" - } English spelling and grammar. - * Cite PolicyEngine ${metadata.countryId.toUpperCase()} v${selectedVersion} and the ${ - metadata.countryId === "uk" - ? "PolicyEngine-enhanced 2019 Family Resources Survey" - : "2022 Current Population Survey March Supplement" - } microdata when describing policy impacts. - * When describing poverty impacts, note that the poverty measure reported is ${ - metadata.countryId === "uk" - ? "absolute poverty before housing costs" - : "the Supplemental Poverty Measure" - }. - * Don't use headers, but do use Markdown formatting. Use - for bullets, and include a newline after each bullet. - * Include the following embeds inline, without a header so it flows. - * Immediately after you describe the changes by decile, include the text: {{decileRelativeImpact}} - * And after the poverty rate changes, include the text: {{povertyImpact}} - ${ - metadata.countryId === "us" - ? "* After the racial breakdown of poverty rate changes, include the text: {{racialPovertyImpact}}" - : "" - } - * And after the inequality changes, include the text: {{inequalityImpact}} - * Make sure to accurately represent the changes observed in the data. - - This JSON snippet describes the default parameter values: ${JSON.stringify( - relevantParameterBaselineValues, - )}\n - This JSON snippet describes the baseline and reform policies being compared: ${JSON.stringify( - policy, - )}\n`; - - const description = `${policyLabel} has the following impacts from the PolicyEngine microsimulation model: - - This JSON snippet describes the relevant parameters with more details: ${JSON.stringify( - relevantParameters, - )} - - This JSON describes the total budgetary impact, the change to tax revenues and benefit spending (ignore 'households' and 'baseline_net_income': ${JSON.stringify( - impact.budget, - )} - - This JSON describes how common different outcomes were at each income decile: ${JSON.stringify( - impact.intra_decile, - )} - - This JSON describes the average and relative changes to income by each income decile: ${JSON.stringify( - impact.decile, - )} - - This JSON describes the baseline and reform poverty rates by age group (describe the relative changes): ${JSON.stringify( - impact.poverty.poverty, - )} - - This JSON describes the baseline and reform deep poverty rates by age group (describe the relative changes): ${JSON.stringify( - impact.poverty.deep_poverty, - )} - - This JSON describes the baseline and reform poverty and deep poverty rates by gender (briefly describe the relative changes): ${JSON.stringify( - impact.poverty_by_gender, - )} - - ${ - metadata.countryId === "us" && - "This JSON describes the baseline and reform poverty impacts by racial group (briefly describe the relative changes): " + - JSON.stringify(impact.poverty_by_race.poverty) - } - - This JSON describes three inequality metrics in the baseline and reform, the Gini coefficient of income inequality, the share of income held by the top 10% of households and the share held by the top 1% (describe the relative changes): ${JSON.stringify( - impact.inequality, - )} - - `; - - return policyDetails + description; -}; diff --git a/src/style/App.css b/src/style/App.css index 5f4c54950..ec3198a55 100644 --- a/src/style/App.css +++ b/src/style/App.css @@ -133,3 +133,21 @@ h1 { h3 { margin-bottom: 15px; } + +.explain-ai-button { + display: none; + position: absolute; + top: 100%; + left: 0; + padding: 5px 10px; + font-family: "Roboto Serif", serif; + color: white; + background-color: #2c6496; + cursor: pointer; + z-index: 1; + transition: background-color 0.3s ease-in-out; +} + +.info-icon-wrapper:hover .explain-ai-button { + display: block; /* Show the button on hover */ +}