diff --git a/.gitignore b/.gitignore index f2e2b9fb1..50130f5af 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ prod-build.env infra/dev/typesense-secrets.yml infra/prod/typesense-secrets.yml + /.venv .python-version __pycache__/ *.pyc @@ -102,3 +103,7 @@ CLAUDE.md #gcloud .gcloudignore +# Python virtual environments +.venv/ +venv/ +env/ diff --git a/components/llm/ChatWidget.module.css b/components/llm/ChatWidget.module.css new file mode 100644 index 000000000..f66a293e1 --- /dev/null +++ b/components/llm/ChatWidget.module.css @@ -0,0 +1,91 @@ +.widget { + display: flex; + flex-direction: column; + max-width: 480px; + height: 480px; + border: 1px solid #d9d9d9; + border-radius: 8px; + overflow: hidden; + font-size: 0.9rem; +} + +.header { + padding: 0.75rem 1rem; + border-bottom: 1px solid #d9d9d9; +} + +.header h3 { + margin: 0; + font-size: 1rem; +} + +.hint { + margin: 0.25rem 0 0; + font-size: 0.75rem; + color: #666; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.welcome { + color: #666; +} + +.message { + padding: 0.5rem 0.75rem; + border-radius: 8px; + max-width: 85%; + white-space: pre-wrap; +} + +.user { + align-self: flex-end; + background: #0a5c36; + color: #fff; +} + +.assistant { + align-self: flex-start; + background: #f1f1f1; + color: #111; +} + +.error { + color: #b00020; + font-size: 0.85rem; +} + +.inputRow { + display: flex; + gap: 0.5rem; + padding: 0.75rem; + border-top: 1px solid #d9d9d9; +} + +.input { + flex: 1; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; +} + +.submit { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + background: #0a5c36; + color: #fff; + cursor: pointer; +} + +.submit:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/components/llm/ChatWidget.tsx b/components/llm/ChatWidget.tsx new file mode 100644 index 000000000..7c5783972 --- /dev/null +++ b/components/llm/ChatWidget.tsx @@ -0,0 +1,104 @@ +import { useState, useRef, useEffect } from "react" +import { httpsCallable } from "firebase/functions" +import { functions } from "components/firebase" +import { useAuth } from "components/auth" +import styles from "./ChatWidget.module.css" + +type AskQuestionRequest = { question: string } +type AskQuestionResponse = { + answer: string + usage: { tokensUsed: number; isLoggedIn: boolean } +} + +const askQuestion = httpsCallable( + functions, + "askQuestion" +) + +type ChatMessage = { + id: string + role: "user" | "assistant" + content: string +} + +/** + * Lightweight bill/policy Q&A chat widget backed by the LangGraph ReAct + * agent (functions/src/llm). Dependency-free beyond firebase/functions, so + * it can be dropped into any page. + */ +export function ChatWidget({ title = "Ask about bills & policy" }: { title?: string }) { + const { user } = useAuth() + const [messages, setMessages] = useState([]) + const [input, setInput] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const endRef = useRef(null) + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [messages, loading]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const question = input.trim() + if (!question || loading) return + + setMessages(prev => [...prev, { id: crypto.randomUUID(), role: "user", content: question }]) + setInput("") + setLoading(true) + setError(null) + + try { + const result = await askQuestion({ question }) + setMessages(prev => [ + ...prev, + { id: crypto.randomUUID(), role: "assistant", content: result.data.answer } + ]) + } catch (err: any) { + setError(err?.message ?? "Something went wrong. Please try again.") + } finally { + setLoading(false) + } + } + + return ( +
+
+

{title}

+ {!user &&

Sign in for a higher daily usage limit.

} +
+ +
+ {messages.length === 0 && ( +

+ Ask a question about a bill, testimony, or ballot question. +

+ )} + {messages.map(message => ( +
+ {message.content} +
+ ))} + {loading &&
Thinking…
} + {error &&
{error}
} +
+
+ +
+ setInput(e.target.value)} + placeholder="e.g. What bills address education funding?" + disabled={loading} + /> + +
+
+ ) +} + +export default ChatWidget diff --git a/docs/REACT_AGENT_ARCHITECTURE.md b/docs/REACT_AGENT_ARCHITECTURE.md new file mode 100644 index 000000000..8bec418c6 --- /dev/null +++ b/docs/REACT_AGENT_ARCHITECTURE.md @@ -0,0 +1,319 @@ +# MAPLE ReACT Agent Architecture + +> **Feature:** Bill & Policy Q&A Chatbot +> **Branch:** `maple_pr_2198_bot` +> **Last updated:** August 2026 + +--- + +## Overview + +The MAPLE chatbot is a LangGraph ReACT agent that answers questions about Massachusetts legislation by semantically searching a Firestore vector index. It supports bills, testimony, and ballot questions, with hearing transcripts and other sources extensible via a one-file pattern. + +--- + +## System Architecture + +``` +User question (browser) + │ + │ Firebase httpsCallable (Auth token attached automatically) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CLOUD FUNCTION │ +│ functions/src/llm/askQuestion.ts │ +│ │ +│ 1. Validate input (zod, max 2000 chars) │ +│ 2. Read context.auth.uid ← server-verified, cannot be forged │ +│ 3. If logged-in: assertWithinBudget(uid) │ +│ 4. Run agent with tier limits │ +│ 5. If logged-in: recordUsage(uid, tokensUsed) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ LANGGRAPH REACT AGENT │ +│ functions/src/llm/agent.ts │ +│ │ +│ createReactAgent(@langchain/langgraph/prebuilt) │ +│ Model: OpenAI gpt-4o-mini • temperature: 0 │ +│ │ +│ ┌─────────┐ ┌──────────┐ ┌─────────────┐ │ +│ │ THINK │───▶│ ACT │───▶│ OBSERVE │ │ +│ │ (LLM) │ │ (tool │ │ (tool result│ │ +│ │ │◀───│ call) │◀───│ in history)│ │ +│ └─────────┘ └──────────┘ └─────────────┘ │ +│ │ │ +│ └── enough context? ──▶ final ANSWER │ +└──────────────────────────┬──────────────────────────────────────┘ + │ tool calls + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ VECTOR SEARCH TOOLS │ +│ functions/src/llm/vectorSearchTools.ts │ +│ │ +│ search_bills collectionGroup("bills") │ +│ search_testimony collectionGroup("publishedTestimony") │ +│ search_ballot_questions collection("ballotQuestions") │ +│ │ +│ Each tool: │ +│ 1. embedText(query) → Vertex AI text-embedding-005 │ +│ 2. findNearest(field, vector, { COSINE, limit: 5 }) │ +│ 3. return formatted text snippets to the agent │ +└──────────────────────────┬──────────────────────────────────────┘ + │ Firestore queries + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ FIRESTORE VECTOR INDEX │ +│ │ +│ generalCourts/{court}/bills/{id} │ +│ vector_embedding ← Title + DocumentText │ +│ │ +│ users/{uid}/publishedTestimony/{id} │ +│ vector_embedding ← content │ +│ │ +│ ballotQuestions/{id} │ +│ vector_embedding ← title + description + fullSummary │ +│ │ +│ llmUsage/{uid}_{YYYY-MM} │ +│ tokensUsed ← monthly budget tracking (logged-in only) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ onWrite triggers (auto-index) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ VECTOR INDEXERS (functions/src/{bills,testimony, │ +│ ballotQuestions}/vector.ts) │ +│ │ +│ All call createVectorIndexer() factory │ +│ • Hash check → skip if text unchanged (saves Vertex AI cost) │ +│ • embedText(text, title) → FieldValue.vector(embedding) │ +│ • Stores 768-dim VectorValue in vector_embedding field │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## File Map + +| File | Role | +|---|---| +| `functions/src/llm/askQuestion.ts` | Cloud Function entry point; auth, budget gate, tier limits | +| `functions/src/llm/agent.ts` | LangGraph `createReactAgent`; ReACT loop; token counting | +| `functions/src/llm/vectorSearchTools.ts` | Three LangChain tools; `findNearest()` wrapper; result formatting | +| `functions/src/llm/embeddings.ts` | Vertex AI `text-embedding-005` (768 dims); shared by indexers + tools | +| `functions/src/llm/usage.ts` | Monthly token budget; `assertWithinBudget`; `recordUsage` | +| `functions/src/llm/config.ts` | All tuning knobs (model, limits, budgets) in one place | +| `functions/src/llm/index.ts` | Exports `askQuestion` for Cloud Functions registration | +| `functions/src/search/createVectorIndexer.ts` | `onWrite` trigger factory; hash-guarded embedding + storage | +| `functions/src/bills/vector.ts` | Bill indexer (`generalCourts/{court}/bills/{id}`) | +| `functions/src/testimony/vector.ts` | Testimony indexer (`users/{uid}/publishedTestimony/{id}`) | +| `functions/src/ballotQuestions/vector.ts` | Ballot question indexer (`ballotQuestions/{id}`) | +| `components/llm/ChatWidget.tsx` | React chat UI; embeddable anywhere; `httpsCallable` | +| `components/llm/ChatWidget.module.css` | Scoped styles; no global leakage | +| `scripts/firebase-admin/backfill-embeddings.ts` | One-time backfill for pre-existing documents | + +--- + +## Embedding Pipeline + +``` +Write path (indexing) Read path (querying) +───────────────────── ──────────────────── +Document created/updated User types question + │ │ + ▼ ▼ +createVectorIndexer.onWrite vectorSearchTools.tool() + │ │ + ▼ ▼ + embedText(text, title) embedText(query) + │ │ + └──────────────┬─────────────────────────┘ + ▼ + Vertex AI text-embedding-005 + 768-dimensional vector + │ + ┌────────────┴──────────────┐ + ▼ ▼ + FieldValue.vector(v) findNearest(COSINE) + stored in Firestore ranked by similarity +``` + +**Critical:** Both paths use the same model (`text-embedding-005`, 768 dims, same title-prefix format). If they ever diverged, COSINE similarity scores would be meaningless. + +--- + +## Cost & Usage Controls + +### Configuration (`config.ts`) + +| Setting | Anonymous | Logged-in | +|---|---|---| +| `maxOutputTokens` | 500 | 800 | +| `recursionLimit` | 6 (~3 tool calls) | 10 (~5 tool calls) | +| Monthly token budget | none (no identity) | 50,000 tokens | + +### How anonymous limits work + +Anonymous users have no persistent identity, so there is no meaningful way to track cross-request usage. Instead, cost is capped per-request via tight `maxOutputTokens` and `recursionLimit` values passed directly into the LangGraph agent. No Firestore read or write is needed. + +### How logged-in limits work + +Before running the agent, `assertWithinBudget(uid)` reads `llmUsage/{uid}_{YYYY-MM}`. If `tokensUsed >= 50000`, it throws `resource-exhausted`. After the agent finishes, `recordUsage` increments the count using `FieldValue.increment` (atomic — safe under concurrent requests). + +Monthly budget resets automatically because the document ID includes the month (`{uid}_2026-08`, `{uid}_2026-09`, etc.). No scheduled job needed. + +### Why `llmUsage` is a top-level collection + +Firestore security rules grant users write access to `users/{uid}/{document=**}`. Putting usage documents there would let a user reset their own counter from the client. The separate `llmUsage` collection is writable only by Cloud Functions (server-side), which prevents this. + +--- + +## Auth Security + +```typescript +// askQuestion.ts +const uid = context.auth?.uid // ← set by Firebase from the caller's Auth token + // client cannot supply or spoof this field +``` + +The Firebase callable SDK automatically attaches the signed-in user's ID token to the request. Firebase verifies the token server-side before the function runs. The `uid` is either valid or `undefined` — there is no way for a client to pass a fake uid. + +Auth state in the frontend (`useAuth()`) is used only to show the "sign in for a higher limit" hint. The actual enforcement is entirely server-side. + +--- + +## Frontend Component + +```tsx +// Drop into any page — requires no props + + +// Optional title override + +``` + +`ChatWidget.tsx` has two external dependencies: +- `firebase/functions` — already in the app +- `components/firebase` — the shared Firebase app instance + +It uses CSS Modules (`ChatWidget.module.css`) so styles are scoped and cannot conflict with the rest of the app. + +--- + +## Adding a New Data Source (e.g. Hearing Transcripts) + +Three steps, no changes to the agent or frontend: + +**Step 1 — Index the collection** (`functions/src/hearingTranscripts/vector.ts`): +```typescript +import { createVectorIndexer } from "../search/createVectorIndexer" + +export const syncHearingTranscriptToVectorIndex = createVectorIndexer({ + documentTrigger: "hearingTranscripts/{id}", + textFields: ["transcript", "title"], + vectorField: "vector_embedding", + titleField: "title" +}) +``` + +**Step 2 — Export from functions index** (`functions/src/index.ts`): +```typescript +export { syncHearingTranscriptToVectorIndex } from "./hearingTranscripts/vector" +``` + +**Step 3 — Add a search tool** (`functions/src/llm/vectorSearchTools.ts`): +```typescript +export const searchHearingTranscriptsTool = tool( + async ({ query }: { query: string }) => { + const embedding = await embedText(query) + const docs = await findNearest( + db.collection("hearingTranscripts"), + embedding, + LLM_CONFIG.vectorSearchTopK + ) + if (docs.length === 0) return "No matching hearing transcripts found." + return docs.map(doc => { + const data = doc.data() + return [ + `Hearing: ${data.title ?? doc.id} (${data.date ?? "unknown date"})`, + `Transcript: ${truncate(data.transcript)}` + ].join("\n") + }).join("\n\n") + }, + { + name: "search_hearing_transcripts", + description: "Semantic search over legislative hearing transcripts. Use this to find what was said at hearings on a bill or topic.", + schema: z.object({ query: z.string() }) + } +) + +// Append to the array: +export const vectorSearchTools = [ + searchBillsTool, + searchTestimonyTool, + searchBallotQuestionsTool, + searchHearingTranscriptsTool // ← new +] +``` + +Then run the backfill script to embed existing transcripts: +```bash +yarn firebase-admin run-script backfill-embeddings --env dev +``` + +--- + +## Backfilling Existing Documents + +The `onWrite` triggers only index documents going forward. To embed documents that existed before the feature was deployed: + +```bash +# Against dev environment +yarn firebase-admin run-script backfill-embeddings --env dev + +# With a limit for testing +yarn firebase-admin run-script backfill-embeddings --env dev --limit 50 + +# Against production (after dev validation) +yarn firebase-admin run-script backfill-embeddings --env prod +``` + +The script skips documents where `vector_embedding` is already a `VectorValue` (has `.toArray()` method). Plain arrays from an older format are re-indexed. + +--- + +## Local Development + +```bash +# Start emulators + Next.js dev server +yarn dev:up + +# Build functions TypeScript only +cd functions && yarn build + +# Run functions tests +cd functions && yarn test +``` + +The `OPENAI_API_KEY` is a Firebase Secret in deployed environments. For local development, set it in `functions/.env`: +``` +OPENAI_API_KEY=sk-... +``` + +Vertex AI calls (`text-embedding-005`) require valid GCP credentials. Set `GOOGLE_APPLICATION_CREDENTIALS` to a service account key file with `aiplatform.endpoints.predict` permission. + +--- + +## Dependencies + +All in `functions/package.json`: + +| Package | Purpose | +|---|---| +| `@langchain/langgraph` `^0.2.0` | ReACT agent state machine (`createReactAgent`) | +| `@langchain/openai` `^0.3.0` | `ChatOpenAI` model wrapper | +| `@langchain/core` `^0.3.0` | `tool()` definition, `BaseMessage` types | +| `@google-cloud/aiplatform` `^3.9.0` | Vertex AI prediction client for embeddings | +| `@google-cloud/firestore` `^5.0.2` | Firestore client (v5 typings; runtime uses Admin v12 bundled v7) | +| `firebase-admin` `^12.0.0` | `FieldValue.vector()`, callable function context | +| `zod` `^3.20.2` | Request validation and tool input schemas | diff --git a/firestore.rules b/firestore.rules index df5659ac5..d64c23bc7 100644 --- a/firestore.rules +++ b/firestore.rules @@ -103,6 +103,13 @@ service cloud.firestore { allow read: if true; allow write: if false; } +<<<<<<< HEAD + match /llmUsage/{id} { + // Tracks per-user monthly token usage for the ReAct Q&A agent. + // Written only by the askQuestion Cloud Function (Admin SDK bypasses + // rules); clients may only read their own usage. + allow read: if request.auth != null && request.auth.uid == resource.data.uid; +======= match /lobbyingRegistrants/{id} { allow read: if true; allow write: if false; @@ -113,6 +120,7 @@ service cloud.firestore { } match /lobbyingMeta/{id} { allow read: if true; +>>>>>>> upstream/main allow write: if false; } match /transcriptions/{tid} { diff --git a/functions/package.json b/functions/package.json index 21b337856..c2c17f330 100644 --- a/functions/package.json +++ b/functions/package.json @@ -16,6 +16,9 @@ "@google-cloud/aiplatform": "^3.9.0", "@google-cloud/firestore": "^5.0.2", "@google-cloud/pubsub": "^3.0.1", + "@langchain/core": "^0.3.0", + "@langchain/langgraph": "^0.2.0", + "@langchain/openai": "^0.3.0", "assemblyai": "^4.9.0", "axios": "^0.25.0", "date-fns": "^2.30.0", @@ -51,7 +54,7 @@ "jest": "^29.7.0", "rimraf": "^3.0.2", "ts-jest": "^29.2.5", - "typescript": "4.5.5" + "typescript": "^5.5.4" }, "private": true } diff --git a/functions/src/index.ts b/functions/src/index.ts index e11b30569..448810d85 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -62,6 +62,8 @@ export { scrapeElections } from "./legislators" export { transcription } from "./webhooks" +export { askQuestion } from "./llm" + export { matchOcpfMembers } from "./ocpf/matchOcpfMembers" export { scrapeOcpfFinance } from "./ocpf/scrapeOcpfFinance" diff --git a/functions/src/llm/agent.ts b/functions/src/llm/agent.ts new file mode 100644 index 000000000..d1fb9d64b --- /dev/null +++ b/functions/src/llm/agent.ts @@ -0,0 +1,52 @@ +import { ChatOpenAI } from "@langchain/openai" +import { createReactAgent } from "@langchain/langgraph/prebuilt" +import { BaseMessage } from "@langchain/core/messages" +import { vectorSearchTools } from "./vectorSearchTools" +import { LLM_CONFIG } from "./config" + +const SYSTEM_PROMPT = `You are a helpful assistant for the MAPLE platform, answering questions about Massachusetts legislation, testimony, and ballot questions. + +Use the search tools to find relevant bills, testimony, and ballot questions before answering - do not rely on prior knowledge of specific bills. Cite bill numbers/IDs when you reference them. If the tools don't return relevant information, say so honestly rather than guessing.` + +export interface AskAgentResult { + answer: string + tokensUsed: number +} + +export async function askAgent( + question: string, + options: { recursionLimit: number; maxOutputTokens: number } +): Promise { + const llm = new ChatOpenAI({ + model: LLM_CONFIG.openaiModel, + temperature: LLM_CONFIG.temperature, + maxTokens: options.maxOutputTokens, + apiKey: process.env.OPENAI_API_KEY + }) + + const reactAgent = createReactAgent({ llm, tools: vectorSearchTools }) + + const result = await reactAgent.invoke( + { + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: question } + ] + }, + { recursionLimit: options.recursionLimit } + ) + + const messages: BaseMessage[] = result.messages + const lastMessage = messages[messages.length - 1] + const answer = + typeof lastMessage.content === "string" + ? lastMessage.content + : JSON.stringify(lastMessage.content) + + const tokensUsed = messages.reduce((sum, message) => { + const usage = (message as any).usage_metadata + return sum + (usage?.total_tokens ?? 0) + }, 0) + + return { answer, tokensUsed } +} diff --git a/functions/src/llm/askQuestion.ts b/functions/src/llm/askQuestion.ts new file mode 100644 index 000000000..1eb0f187a --- /dev/null +++ b/functions/src/llm/askQuestion.ts @@ -0,0 +1,47 @@ +import * as functions from "firebase-functions" +import { z } from "zod" +import { checkRequestZod } from "../common" +import { askAgent } from "./agent" +import { assertWithinBudget, recordUsage } from "./usage" +import { LLM_CONFIG } from "./config" + +const Request = z.object({ + question: z.string().min(1).max(2000) +}) + +/** + * Callable function for the bill/policy Q&A chat widget. Auth state comes + * from the verified `context.auth` (never from client-supplied fields), so + * anonymous vs logged-in cost limits can't be spoofed. + */ +export const askQuestion = functions + .runWith({ secrets: ["OPENAI_API_KEY"], timeoutSeconds: 120, memory: "512MB" }) + .https.onCall(async (data, context) => { + const { question } = checkRequestZod(Request, data) + const uid = context.auth?.uid + + if (uid) { + await assertWithinBudget(uid) + } + + const { answer, tokensUsed } = await askAgent(question, { + recursionLimit: uid + ? LLM_CONFIG.recursionLimit + : LLM_CONFIG.anonymousRecursionLimit, + maxOutputTokens: uid + ? LLM_CONFIG.maxOutputTokens + : LLM_CONFIG.anonymousMaxOutputTokens + }) + + if (uid) { + await recordUsage(uid, tokensUsed) + } + + return { + answer, + usage: { + tokensUsed, + isLoggedIn: Boolean(uid) + } + } + }) diff --git a/functions/src/llm/config.ts b/functions/src/llm/config.ts new file mode 100644 index 000000000..7aecf2620 --- /dev/null +++ b/functions/src/llm/config.ts @@ -0,0 +1,24 @@ +export const LLM_CONFIG = { + // Reasoning/generation model (embeddings are handled separately by + // Vertex AI text-embedding-005 - see embeddings.ts). + openaiModel: "gpt-4o-mini", + temperature: 0, + maxOutputTokens: 800, + + // ReAct loop bound. LangGraph counts each node transition, so this allows + // roughly (recursionLimit / 2) tool calls before forcing a final answer. + recursionLimit: 10, + + // Number of documents to return per vector search tool call. + vectorSearchTopK: 5, + + // Anonymous users: no persistent identity, so cost control is a small + // fixed per-request ceiling only (see usage.ts for why this can't be + // tracked across requests). + anonymousMaxOutputTokens: 500, + anonymousRecursionLimit: 6, + + // Logged-in users: persistent monthly token budget, tracked in the + // top-level `llmUsage` collection (see usage.ts). + loggedInMonthlyTokenBudget: 50_000 +} diff --git a/functions/src/llm/embeddings.ts b/functions/src/llm/embeddings.ts new file mode 100644 index 000000000..e870bbc9c --- /dev/null +++ b/functions/src/llm/embeddings.ts @@ -0,0 +1,57 @@ +import { PredictionServiceClient, helpers } from "@google-cloud/aiplatform" +import { app } from "../firebase" + +const LOCATION = "us-central1" +const PUBLISHER = "google" +const MODEL = "text-embedding-005" +export const EMBEDDING_DIMENSION = 768 + +let client: PredictionServiceClient | undefined + +function getClient(): PredictionServiceClient { + if (!client) { + client = new PredictionServiceClient({ + apiEndpoint: `${LOCATION}-aiplatform.googleapis.com` + }) + } + return client +} + +/** + * Embeds text with Vertex AI text-embedding-005 (768 dimensions). Shared by + * the Firestore vector indexers (search/createVectorIndexer.ts) and the + * ReAct agent's retrieval tools so query-time and index-time embeddings stay + * in the same vector space. + */ +export async function embedText( + text: string, + title = "none" +): Promise { + const project = app.options.projectId + const endpoint = `projects/${project}/locations/${LOCATION}/publishers/${PUBLISHER}/models/${MODEL}` + + const formattedText = `title: ${title} | text: ${text}` + const instance = helpers.toValue({ content: formattedText })! + const parameters = helpers.toValue({ + outputDimensionality: EMBEDDING_DIMENSION + })! + const responseArray = (await getClient().predict({ + endpoint, + instances: [instance], + parameters + })) as any + const response = responseArray[0] + + if (!response.predictions || response.predictions.length === 0) { + throw new Error("No predictions returned from Vertex AI") + } + + const prediction = helpers.fromValue(response.predictions[0] as any) as any + const embedding = prediction.embeddings?.values || prediction.embedding?.values + + if (!embedding) { + throw new Error(`Unexpected prediction format: ${JSON.stringify(prediction)}`) + } + + return embedding +} diff --git a/functions/src/llm/index.ts b/functions/src/llm/index.ts new file mode 100644 index 000000000..13dcb7b0e --- /dev/null +++ b/functions/src/llm/index.ts @@ -0,0 +1 @@ +export { askQuestion } from "./askQuestion" diff --git a/functions/src/llm/usage.ts b/functions/src/llm/usage.ts new file mode 100644 index 000000000..afb81d8f3 --- /dev/null +++ b/functions/src/llm/usage.ts @@ -0,0 +1,45 @@ +import { db, FieldValue } from "../firebase" +import { fail } from "../common" +import { LLM_CONFIG } from "./config" + +function currentPeriod(): string { + const now = new Date() + return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}` +} + +function usageDocId(uid: string): string { + return `${uid}_${currentPeriod()}` +} + +/** + * Throws if a logged-in user has exhausted their monthly token budget. + * Stored in a top-level `llmUsage` collection (not under `users/{uid}/...`) + * because the existing rule `users/{userId}/{document=**}` grants the owner + * client-side write access, which would let a client reset its own counter. + */ +export async function assertWithinBudget(uid: string): Promise { + const doc = await db.collection("llmUsage").doc(usageDocId(uid)).get() + const tokensUsed = doc.data()?.tokensUsed ?? 0 + + if (tokensUsed >= LLM_CONFIG.loggedInMonthlyTokenBudget) { + throw fail( + "resource-exhausted", + `Monthly usage limit reached (${LLM_CONFIG.loggedInMonthlyTokenBudget} tokens). Limit resets next month.` + ) + } +} + +export async function recordUsage(uid: string, tokensUsed: number): Promise { + await db + .collection("llmUsage") + .doc(usageDocId(uid)) + .set( + { + uid, + period: currentPeriod(), + tokensUsed: FieldValue.increment(tokensUsed), + updatedAt: FieldValue.serverTimestamp() + }, + { merge: true } + ) +} diff --git a/functions/src/llm/vectorSearchTools.ts b/functions/src/llm/vectorSearchTools.ts new file mode 100644 index 000000000..2ae01db78 --- /dev/null +++ b/functions/src/llm/vectorSearchTools.ts @@ -0,0 +1,152 @@ +import { Query } from "firebase-admin/firestore" +import { tool } from "@langchain/core/tools" +import { z } from "zod" +import { db, DocumentData, QueryDocumentSnapshot } from "../firebase" +import { embedText } from "./embeddings" +import { LLM_CONFIG } from "./config" + +const VECTOR_FIELD = "vector_embedding" +const MAX_SNIPPET_LENGTH = 800 + +function truncate(text: string | undefined, length = MAX_SNIPPET_LENGTH): string { + if (!text) return "" + return text.length > length ? `${text.slice(0, length)}...` : text +} + +/** + * Runs a Firestore vector similarity search against `query`. + * + * firebase-admin's bundled Firestore client (v7) supports `findNearest()`, + * but this project's direct `@google-cloud/firestore` dependency is pinned + * at v5, whose typings predate it - the same situation documented in + * `search/createVectorIndexer.ts` for `FieldValue.vector()`. Cast to bridge + * the type gap. + */ +async function findNearest( + query: Query, + embedding: number[], + limit: number +): Promise[]> { + const vectorQuery = query as unknown as { + findNearest( + field: string, + queryVector: number[], + options: { limit: number; distanceMeasure: "COSINE" } + ): { get(): Promise<{ docs: QueryDocumentSnapshot[] }> } + } + + const snapshot = await vectorQuery + .findNearest(VECTOR_FIELD, embedding, { + limit, + distanceMeasure: "COSINE" + }) + .get() + + return snapshot.docs +} + +export const searchBillsTool = tool( + async ({ query }: { query: string }) => { + const embedding = await embedText(query) + const docs = await findNearest( + db.collectionGroup("bills"), + embedding, + LLM_CONFIG.vectorSearchTopK + ) + + if (docs.length === 0) return "No matching bills found." + + return docs + .map(doc => { + const data = doc.data() + const court = doc.ref.parent.parent?.id ?? "unknown" + return [ + `Bill ${data.id ?? doc.id} (court ${court})`, + `Title: ${data.content?.Title ?? "Unknown"}`, + `Text: ${truncate(data.content?.DocumentText)}` + ].join("\n") + }) + .join("\n\n") + }, + { + name: "search_bills", + description: + "Semantic search over Massachusetts legislative bills (title and full text). Use this to find bills related to a topic, policy area, or question.", + schema: z.object({ + query: z.string().describe("A natural-language description of the bill topic to search for") + }) + } +) + +export const searchTestimonyTool = tool( + async ({ query, billId }: { query: string; billId?: string }) => { + const embedding = await embedText(query) + let base: Query = db.collectionGroup("publishedTestimony") + if (billId) base = base.where("billId", "==", billId) + + const docs = await findNearest(base, embedding, LLM_CONFIG.vectorSearchTopK) + + if (docs.length === 0) return "No matching testimony found." + + return docs + .map(doc => { + const data = doc.data() + return [ + `Testimony on bill ${data.billId ?? "unknown"} (${data.billTitle ?? "unknown title"})`, + `Author: ${data.authorDisplayName ?? "anonymous"}`, + `Content: ${truncate(data.content)}` + ].join("\n") + }) + .join("\n\n") + }, + { + name: "search_testimony", + description: + "Semantic search over public testimony submitted on bills. Optionally scope to a specific bill by ID. Use this to find what people have said about a bill or issue.", + schema: z.object({ + query: z.string().describe("A natural-language description of the testimony content to search for"), + billId: z.string().optional().describe("Optional bill ID to restrict results to testimony on that bill") + }) + } +) + +export const searchBallotQuestionsTool = tool( + async ({ query }: { query: string }) => { + const embedding = await embedText(query) + const docs = await findNearest( + db.collection("ballotQuestions"), + embedding, + LLM_CONFIG.vectorSearchTopK + ) + + if (docs.length === 0) return "No matching ballot questions found." + + return docs + .map(doc => { + const data = doc.data() + return [ + `Ballot Question ${doc.id} (${data.electionYear ?? "unknown year"}, status: ${data.ballotStatus ?? "unknown"})`, + `Title: ${data.title ?? "Unknown"}`, + `Summary: ${truncate(data.fullSummary ?? data.description)}` + ].join("\n") + }) + .join("\n\n") + }, + { + name: "search_ballot_questions", + description: + "Semantic search over statewide ballot questions (title, description, and summary). Use this for questions about ballot initiatives or referenda.", + schema: z.object({ + query: z.string().describe("A natural-language description of the ballot question topic to search for") + }) + } +) + +// Adding a new source type (e.g. hearing transcripts) later just means +// indexing it with createVectorIndexer and adding one more tool() here in +// the same shape. +export const vectorSearchTools = [ + searchBillsTool, + searchTestimonyTool, + searchBallotQuestionsTool +] diff --git a/functions/src/search/createVectorIndexer.ts b/functions/src/search/createVectorIndexer.ts index 97a758e1c..31f49fce5 100644 --- a/functions/src/search/createVectorIndexer.ts +++ b/functions/src/search/createVectorIndexer.ts @@ -1,8 +1,7 @@ import { runWith } from "firebase-functions" -import * as admin from "firebase-admin" import { FieldValue } from "firebase-admin/firestore" -import { PredictionServiceClient, helpers } from "@google-cloud/aiplatform" import hash from "object-hash" +import { embedText } from "../llm/embeddings" export interface VectorIndexerConfig { documentTrigger: string @@ -12,10 +11,6 @@ export interface VectorIndexerConfig { } export function createVectorIndexer(config: VectorIndexerConfig) { - const location = "us-central1" - const publisher = "google" - const model = "text-embedding-005" - return runWith({ timeoutSeconds: 60, memory: "512MB" @@ -57,39 +52,7 @@ export function createVectorIndexer(config: VectorIndexerConfig) { return // Nothing changed } - // Initialize Vertex AI client - const project = admin.app().options.projectId - const endpoint = `projects/${project}/locations/${location}/publishers/${publisher}/models/${model}` - const client = new PredictionServiceClient({ - apiEndpoint: `${location}-aiplatform.googleapis.com` - }) - - // Get embedding with multimodal/task prefix - const formattedText = `title: ${title} | text: ${textToEmbed}` - const instance = helpers.toValue({ content: formattedText })! - const parameters = helpers.toValue({ outputDimensionality: 768 })! - const responseArray = (await client.predict({ - endpoint, - instances: [instance], - parameters - })) as any - const response = responseArray[0] - - if (!response.predictions || response.predictions.length === 0) { - throw new Error("No predictions returned from Vertex AI") - } - - const prediction = helpers.fromValue( - response.predictions[0] as any - ) as any - const embedding = - prediction.embeddings?.values || prediction.embedding?.values - - if (!embedding) { - throw new Error( - `Unexpected prediction format: ${JSON.stringify(prediction)}` - ) - } + const embedding = await embedText(textToEmbed, title) // Update document. The embedding must be stored as a Firestore // VectorValue (not a plain array) for the vector index / findNearest to diff --git a/functions/yarn.lock b/functions/yarn.lock index c4190ab58..55cf5c441 100644 --- a/functions/yarn.lock +++ b/functions/yarn.lock @@ -469,6 +469,11 @@ resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@cfworker/json-schema@^4.0.2": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6" + integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og== + "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz" @@ -1117,6 +1122,61 @@ dependencies: lodash "^4.17.21" +"@langchain/core@^0.3.0": + version "0.3.80" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-0.3.80.tgz#c494a6944e53ab28bf32dc531e257b17cfc8f797" + integrity sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA== + dependencies: + "@cfworker/json-schema" "^4.0.2" + ansi-styles "^5.0.0" + camelcase "6" + decamelize "1.2.0" + js-tiktoken "^1.0.12" + langsmith "^0.3.67" + mustache "^4.2.0" + p-queue "^6.6.2" + p-retry "4" + uuid "^10.0.0" + zod "^3.25.32" + zod-to-json-schema "^3.22.3" + +"@langchain/langgraph-checkpoint@~0.0.17": + version "0.0.18" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.18.tgz#2f7a9cdeda948ccc8d312ba9463810709d71d0b8" + integrity sha512-IS7zJj36VgY+4pf8ZjsVuUWef7oTwt1y9ylvwu0aLuOn1d0fg05Om9DLm3v2GZ2Df6bhLV1kfWAM0IAl9O5rQQ== + dependencies: + uuid "^10.0.0" + +"@langchain/langgraph-sdk@~0.0.32": + version "0.0.112" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.112.tgz#3186919b60e3381aa8aa32ea9b9c39df1f02a9fd" + integrity sha512-/9W5HSWCqYgwma6EoOspL4BGYxGxeJP6lIquPSF4FA0JlKopaUv58ucZC3vAgdJyCgg6sorCIV/qg7SGpEcCLw== + dependencies: + "@types/json-schema" "^7.0.15" + p-queue "^6.6.2" + p-retry "4" + uuid "^9.0.0" + +"@langchain/langgraph@^0.2.0": + version "0.2.74" + resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-0.2.74.tgz#37367a1e8bafda3548037a91449a69a84f285def" + integrity sha512-oHpEi5sTZTPaeZX1UnzfM2OAJ21QGQrwReTV6+QnX7h8nDCBzhtipAw1cK616S+X8zpcVOjgOtJuaJhXa4mN8w== + dependencies: + "@langchain/langgraph-checkpoint" "~0.0.17" + "@langchain/langgraph-sdk" "~0.0.32" + uuid "^10.0.0" + zod "^3.23.8" + +"@langchain/openai@^0.3.0": + version "0.3.17" + resolved "https://registry.yarnpkg.com/@langchain/openai/-/openai-0.3.17.tgz#5b8613ac8d849da90f3ecd8368ae7389fca4ee13" + integrity sha512-uw4po32OKptVjq+CYHrumgbfh4NuD7LqyE+ZgqY9I/LrLc6bHLMc+sisHmI17vgek0K/yqtarI0alPJbzrwyag== + dependencies: + js-tiktoken "^1.0.12" + openai "^4.77.0" + zod "^3.22.4" + zod-to-json-schema "^3.22.3" + "@nodable/entities@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-2.1.0.tgz#f543e5c6446720d4cf9e498a83019dd159973bc2" @@ -1442,9 +1502,9 @@ "@types/tough-cookie" "*" parse5 "^7.0.0" -"@types/json-schema@^7.0.6": +"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.6": version "7.0.15" - resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== "@types/jsonwebtoken@^9.0.4": @@ -1508,6 +1568,14 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== +"@types/node-fetch@^2.6.4": + version "2.6.13" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.13.tgz#e0c9b7b5edbdb1b50ce32c127e85e880872d56ee" + integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw== + dependencies: + "@types/node" "*" + form-data "^4.0.4" + "@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0": version "20.10.4" resolved "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz" @@ -1515,6 +1583,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@^18.11.18": + version "18.19.130" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" + integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== + dependencies: + undici-types "~5.26.4" + "@types/node@^22.0.1": version "22.19.19" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.19.tgz#3124bf26ded54168b768138321fef99b420c6112" @@ -1554,6 +1629,11 @@ "@types/tough-cookie" "*" form-data "^2.5.5" +"@types/retry@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/rimraf@^3.0.2": version "3.0.2" resolved "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.2.tgz" @@ -1601,6 +1681,11 @@ dependencies: "@types/node" "*" +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz" @@ -1662,6 +1747,13 @@ agent-base@^7.1.2: resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz" integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== +agentkeepalive@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz" @@ -1967,7 +2059,7 @@ bare-events@^2.2.0: resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.6.1.tgz#f793b28bdc3dcf147d7cf01f882a6f0b12ccc4a2" integrity sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g== -base64-js@^1.3.0, base64-js@^1.3.1: +base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -2221,16 +2313,16 @@ callsites@^3.0.0: resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== +camelcase@6, camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + camelcase@^5.3.1: version "5.3.1" resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0: - version "6.3.0" - resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - caniuse-lite@^1.0.30001565: version "1.0.30001568" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001568.tgz" @@ -2257,7 +2349,7 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1: +chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -2544,6 +2636,13 @@ connect@^3.7.0: parseurl "~1.3.3" utils-merge "1.0.1" +console-table-printer@^2.12.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.16.1.tgz#1137bec8db0267d9552b5e243d99a88f08702999" + integrity sha512-Sc9FRJ4O9xKGNrvulNdPfK5SyBcZ6lcaRnDE4AQ/uw6IDtjHhsqyzzqcnMikjyGaiOOF2tNOKoBhbVjRvFy9Lw== + dependencies: + simple-wcswidth "^1.1.2" + content-disposition@0.5.4: version "0.5.4" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz" @@ -2726,6 +2825,11 @@ debug@^4.4.0: dependencies: ms "^2.1.3" +decamelize@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + decimal.js@^10.4.3: version "10.5.0" resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz" @@ -3079,6 +3183,11 @@ event-target-shim@^5.0.0: resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + events-listener@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/events-listener/-/events-listener-1.1.0.tgz" @@ -3490,6 +3599,11 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + form-data@^2.5.5: version "2.5.5" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.5.tgz#a5f6364ad7e4e67e95b4a07e2d8c6f711c74f624" @@ -3512,6 +3626,25 @@ form-data@^4.0.1: es-set-tostringtag "^2.1.0" mime-types "^2.1.12" +form-data@^4.0.4: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + +formdata-node@^4.3.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + formdata-polyfill@^4.0.10: version "4.0.10" resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" @@ -4037,6 +4170,13 @@ hasown@^2.0.0, hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + heap-js@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/heap-js/-/heap-js-2.3.0.tgz" @@ -4131,6 +4271,13 @@ human-signals@^2.1.0: resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" @@ -4880,6 +5027,13 @@ js-sha256@^0.11.0: resolved "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz" integrity sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q== +js-tiktoken@^1.0.12: + version "1.0.21" + resolved "https://registry.yarnpkg.com/js-tiktoken/-/js-tiktoken-1.0.21.tgz#368a9957591a30a62997dd0c4cf30866f00f8221" + integrity sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g== + dependencies: + base64-js "^1.5.1" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" @@ -5098,6 +5252,18 @@ kuler@^2.0.0: resolved "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz" integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== +langsmith@^0.3.67: + version "0.3.87" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.3.87.tgz#f1c991c93a5d4d226a31671be7e4443b4b8673b1" + integrity sha512-XXR1+9INH8YX96FKWc5tie0QixWz6tOqAsAKfcJyPkE0xPep+NDz0IQLR32q4bn10QK3LqD2HN6T3n6z1YLW7Q== + dependencies: + "@types/uuid" "^10.0.0" + chalk "^4.1.2" + console-table-printer "^2.12.1" + p-queue "^6.6.2" + semver "^7.6.3" + uuid "^10.0.0" + lazystream@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz" @@ -5604,11 +5770,16 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1, ms@^2.1.3: +ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + mute-stream@0.0.8: version "0.0.8" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz" @@ -5663,7 +5834,7 @@ netmask@^2.0.2: resolved "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz" integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== -node-domexception@^1.0.0: +node-domexception@1.0.0, node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== @@ -5834,6 +6005,19 @@ open@^6.3.0: dependencies: is-wsl "^1.1.0" +openai@^4.77.0: + version "4.104.0" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.104.0.tgz#c489765dc051b95019845dab64b0e5207cae4d30" + integrity sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + openapi3-ts@^3.1.1: version "3.2.0" resolved "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-3.2.0.tgz" @@ -5873,6 +6057,11 @@ p-defer@^3.0.0: resolved "https://registry.npmjs.org/p-defer/-/p-defer-3.0.0.tgz" integrity sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw== +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" @@ -5901,11 +6090,34 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" +p-queue@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" + integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== + dependencies: + eventemitter3 "^4.0.4" + p-timeout "^3.2.0" + +p-retry@4: + version "4.6.2" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" + integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== + dependencies: + "@types/retry" "0.12.0" + retry "^0.13.1" + p-throttle@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/p-throttle/-/p-throttle-7.0.0.tgz#d2650e884dad46fd626a9a5cfc3fb239cb799dee" integrity sha512-aio0v+S0QVkH1O+9x4dHtD4dgCExACcL+3EtNaGqC01GBudS9ijMuUsmN8OVScyV4OOp0jqdLShZFuSlbL/AsA== +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" @@ -6768,6 +6980,11 @@ semver@^7.5.2: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +semver@^7.6.3: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + semver@^7.7.3: version "7.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" @@ -6895,6 +7112,11 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +simple-wcswidth@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b" + integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw== + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" @@ -7469,10 +7691,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@4.5.5: - version "4.5.5" - resolved "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@^5.5.4: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== typesense@^1.2.2: version "1.7.2" @@ -7687,6 +7909,11 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + web-streams-polyfill@^3.0.3: version "3.3.3" resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" @@ -7987,7 +8214,17 @@ zip-stream@^6.0.1: compress-commons "^6.0.2" readable-stream "^4.0.0" +zod-to-json-schema@^3.22.3: + version "3.25.2" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz#3fa799a7badd554541472fb65843fdc460b2e5aa" + integrity sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA== + zod@^3.20.2: version "3.22.4" resolved "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz" integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg== + +zod@^3.22.4, zod@^3.23.8, zod@^3.25.32: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== diff --git a/llm/requirements.txt b/llm/requirements.txt index d76b88efa..5bc28fabf 100644 --- a/llm/requirements.txt +++ b/llm/requirements.txt @@ -13,5 +13,4 @@ requests==2.32.3 rouge_score==0.1.2 ruff==0.14.5 scikit-learn==1.5.0 -streamlit==1.35.0 tiktoken==0.7.0