Skip to content

Commit

Permalink
Infer unit/metric from question title
Browse files Browse the repository at this point in the history
  • Loading branch information
IanPhilips committed Feb 27, 2025
1 parent 99e41de commit 95d7ab5
Show file tree
Hide file tree
Showing 10 changed files with 178 additions and 4 deletions.
1 change: 1 addition & 0 deletions backend/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@giphy/js-fetch-api": "5.0.0",
"@google-cloud/monitoring": "4.0.0",
"@google-cloud/secret-manager": "4.2.1",
"@google/generative-ai": "0.22.0",
"@mendable/firecrawl-js": "1.8.5",
"@supabase/supabase-js": "2.38.5",
"@tiptap/core": "2.0.0-beta.204",
Expand Down
50 changes: 50 additions & 0 deletions backend/api/src/infer-numeric-unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { APIError, APIHandler } from './helpers/endpoint'
import { track } from 'shared/analytics'
import { rateLimitByUser } from './helpers/rate-limit'
import { HOUR_MS } from 'common/util/time'
import { promptGemini, parseGeminiResponseAsJson } from 'shared/helpers/gemini'
import { log } from 'shared/utils'

export const inferNumericUnit: APIHandler<'infer-numeric-unit'> =
rateLimitByUser(
async (props, auth) => {
const { question, description } = props

try {
const systemPrompt = `
You are an AI assistant that extracts the most appropriate unit of measurement from prediction market questions.
You will return ONLY a JSON object with a single "unit" field containing the inferred unit as a string.
For example: {"unit": "people"}
Guidelines:
- If no specific unit is mentioned, infer the most logical unit based on the context
- Common units include: people, dollars, percent, points, votes, etc.
- If the question is about a count of items, use the plural form (e.g., "people" not "person")
- If no unit can be reasonably inferred, return an empty json object
`

const prompt = `
Question: ${question}
${
description && description !== '<p></p>'
? `Description: ${description}`
: ''
}
`
const response = await promptGemini(prompt, { system: systemPrompt })
const result = parseGeminiResponseAsJson(response)
log.info('Inferred unit:', { result })

track(auth.uid, 'infer-numeric-unit', {
question,
inferred_unit: result.unit,
})

return { unit: result.unit }
} catch (error) {
log.error('Error inferring unit:', { error })
throw new APIError(500, 'Failed to infer unit from question')
}
},
{ maxCalls: 60, windowMs: HOUR_MS }
)
2 changes: 2 additions & 0 deletions backend/api/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ import {
generateAIDateRanges,
regenerateDateMidpoints,
} from './generate-ai-date-ranges'
import { inferNumericUnit } from './infer-numeric-unit'
// we define the handlers in this object in order to typecheck that every API has a handler
export const handlers: { [k in APIPath]: APIHandler<k> } = {
'refresh-all-clients': refreshAllClients,
Expand Down Expand Up @@ -352,6 +353,7 @@ export const handlers: { [k in APIPath]: APIHandler<k> } = {
'purchase-contract-boost': purchaseContractBoost,
'generate-ai-numeric-ranges': generateAINumericRanges,
'regenerate-numeric-midpoints': regenerateNumericMidpoints,
'infer-numeric-unit': inferNumericUnit,
'generate-ai-date-ranges': generateAIDateRanges,
'regenerate-date-midpoints': regenerateDateMidpoints,
}
1 change: 1 addition & 0 deletions backend/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@anthropic-ai/sdk": "0.24.3",
"@google-cloud/monitoring": "4.0.0",
"@google-cloud/secret-manager": "4.2.1",
"@google/generative-ai": "0.22.0",
"@stdlib/math-base-special-betaincinv": "0.2.1",
"@tiptap/core": "2.0.0-beta.204",
"@tiptap/html": "2.0.0-beta.204",
Expand Down
82 changes: 82 additions & 0 deletions backend/shared/src/helpers/gemini.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { GoogleGenerativeAI } from '@google/generative-ai'
import { log } from 'shared/utils'
import { APIError } from 'common/api/utils'

export const models = {
flash: 'gemini-2.0-flash' as const,
}

export type model_types = (typeof models)[keyof typeof models]

export const promptGemini = async (
prompt: string,
options: { system?: string; model?: model_types } = {}
) => {
const { model = models.flash, system } = options

const apiKey = process.env.GEMINI_API_KEY

if (!apiKey) {
throw new APIError(500, 'Missing GEMINI_API_KEY')
}

const genAI = new GoogleGenerativeAI(apiKey)
const geminiModel = genAI.getGenerativeModel({ model })

try {
// Combine system prompt and user prompt if system is provided
const fullPrompt = system ? `${system}\n\n${prompt}` : prompt

const result = await geminiModel.generateContent(fullPrompt)
const response = result.response.text()

log('Gemini returned message:', response)
return response
} catch (error: any) {
log.error(`Error with Gemini API: ${error.message}`)
throw new APIError(500, 'Failed to get response from Gemini')
}
}

// Helper function to clean Gemini responses from markdown formatting
const removeJsonTicksFromGeminiResponse = (response: string): string => {
// Remove markdown code block formatting if present
const jsonBlockRegex = /```(?:json)?\s*([\s\S]*?)```/
const match = response.match(jsonBlockRegex)

if (match && match[1]) {
return match[1].trim()
}

// If no markdown formatting found, return the original response
return response.trim()
}

// Helper function to ensure the response is valid JSON
export const parseGeminiResponseAsJson = (response: string): any => {
const cleanedResponse = removeJsonTicksFromGeminiResponse(response)

try {
// Try to parse as is
return JSON.parse(cleanedResponse)
} catch (error) {
// If parsing fails, try to handle common issues

// Check if it's an array wrapped in extra text
const arrayStart = cleanedResponse.indexOf('[')
const arrayEnd = cleanedResponse.lastIndexOf(']')

if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {
const potentialArray = cleanedResponse.substring(arrayStart, arrayEnd + 1)
try {
return JSON.parse(potentialArray)
} catch (e) {

Check warning on line 73 in backend/shared/src/helpers/gemini.ts

View workflow job for this annotation

GitHub Actions / test

'e' is defined but never used. Allowed unused caught errors must match /^_/u
// If still fails, throw the original error
throw error
}
}

// If we can't fix it, throw the original error
throw error
}
}
14 changes: 14 additions & 0 deletions common/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2191,6 +2191,20 @@ export const API = (_apiTypeCheck = {
})
.strict(),
},
'infer-numeric-unit': {
method: 'POST',
visibility: 'public',
authed: true,
returns: {} as {
unit: string
},
props: z
.object({
question: z.string(),
description: z.string().optional(),
})
.strict(),
},
'generate-ai-date-ranges': {
method: 'POST',
visibility: 'public',
Expand Down
1 change: 1 addition & 0 deletions common/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const secrets = (
'FIRECRAWL_API_KEY',
'SPORTSDB_KEY',
'VERIFIED_PHONE_NUMBER',
'GEMINI_API_KEY',
// Some typescript voodoo to keep the string literal types while being not readonly.
] as const
).concat()
Expand Down
20 changes: 19 additions & 1 deletion web/components/new-contract/contract-params-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,21 @@ export function ContractParamsForm(props: {
}
}

const inferUnit = async () => {
if (!question || unit !== '') return
try {
const result = await api('infer-numeric-unit', {
question,
description: editor?.getHTML(),
})
if (result.unit) {
setUnit(result.unit)
}
} catch (e) {
console.error('Error inferring unit:', e)
}
}

return (
<Col className="gap-6">
<Col>
Expand All @@ -608,7 +623,10 @@ export function ContractParamsForm(props: {
maxLength={MAX_QUESTION_LENGTH}
value={question}
onChange={(e) => setQuestion(e.target.value || '')}
onBlur={(e) => findTopicsAndSimilarQuestions(e.target.value || '')}
onBlur={(e) => {
if (outcomeType === 'MULTI_NUMERIC') inferUnit()
findTopicsAndSimilarQuestions(e.target.value || '')
}}
/>
</Col>
{similarContracts.length ? (
Expand Down
6 changes: 3 additions & 3 deletions web/components/new-contract/multi-numeric-range-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export const MultiNumericRangeSection = (props: {
useEffect(() => {
if (isTimeUnit(unit)) {
setError(
'Time units are not supported for numeric ranges. Date ranges are coming soon!'
'Time metrics are not supported for numeric ranges. Date ranges are coming soon!'
)
} else {
setError('')
Expand Down Expand Up @@ -320,7 +320,7 @@ export const MultiNumericRangeSection = (props: {
<Row className={'flex-wrap gap-x-4'}>
<Col className="mb-2 items-start">
<Row className=" items-baseline gap-1 px-1 py-2">
<span className="">Range & unit</span>
<span className="">Range & metric</span>
<InfoTooltip text="The lower and higher bounds of the numeric range. Choose bounds the value could reasonably be expected to hit." />
{minMaxError && (
<span className="text-scarlet-500 text-sm">
Expand Down Expand Up @@ -355,7 +355,7 @@ export const MultiNumericRangeSection = (props: {
<Input
type="text"
className="w-[7.25rem]"
placeholder="Unit"
placeholder="Metric"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setUnit(e.target.value)}
onBlur={handleRangeBlur}
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2269,6 +2269,11 @@
teeny-request "^8.0.0"
uuid "^8.0.0"

"@google/generative-ai@0.22.0":
version "0.22.0"
resolved "https://registry.yarnpkg.com/@google/generative-ai/-/generative-ai-0.22.0.tgz#e77a1a3911f4f98bf9e965ad7c4b1ee2130abd26"
integrity sha512-mLR3PDWCk5O/BWNyDvFDIiwKeXQmFGZ+kJFd9m73QrUPCFREttJyVbBPTW4y9CwTbaltLMDaLDfroCrRv5Bl8Q==

"@grpc/grpc-js@~1.10.0":
version "1.10.6"
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.10.6.tgz#1e3eb1af911dc888fbef7452f56a7573b8284d54"
Expand Down

0 comments on commit 95d7ab5

Please sign in to comment.