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..ca0d0b467 --- /dev/null +++ b/docs/REACT_AGENT_ARCHITECTURE.md @@ -0,0 +1,319 @@ +# MAPLE ReACT Agent — Architecture & Search Tool Design + +> **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..15a1985c9 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/google-vertexai": "^0.1.0", + "@langchain/langgraph": "^0.2.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": "^4.5.5" }, "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..44e7b1b2e --- /dev/null +++ b/functions/src/llm/agent.ts @@ -0,0 +1,54 @@ +import { ChatVertexAI } from "@langchain/google-vertexai" +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 ChatVertexAI({ + model: LLM_CONFIG.geminiModel, + temperature: LLM_CONFIG.temperature, + maxOutputTokens: options.maxOutputTokens + // Credentials come from Application Default Credentials (ADC) — + // the same GCP service account used by Vertex AI embeddings. + // No API key env var required. + }) + + 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..a0eb8337d --- /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({ 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..fec3c6470 --- /dev/null +++ b/functions/src/llm/config.ts @@ -0,0 +1,25 @@ +export const LLM_CONFIG = { + // Reasoning/generation model. Uses Vertex AI (same GCP credentials as + // embeddings — no separate API key required). + // text-embedding-005 is still used for embeddings (see embeddings.ts). + geminiModel: "gemini-3.7-flash", + 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/policySearch.ts b/functions/src/llm/policySearch.ts new file mode 100644 index 000000000..1c21fa6ff --- /dev/null +++ b/functions/src/llm/policySearch.ts @@ -0,0 +1,278 @@ +/** + * Core Firestore vector search helpers for policy content (bills + ballot + * questions). These functions are the single source of truth for how the + * ReAct agent retrieves policy documents — reused by vectorSearchTools.ts + * so that each LangChain tool is a thin wrapper rather than duplicating + * Firestore/embedding logic. + * + * The MCP server (mcp-server/tools.ts) owns its own copy of these queries + * because it runs in a separate process with a separate Firestore client and + * a slightly different embedding call convention (isQuery task-type prefix). + * If the two ever diverge materially, consolidate into a shared library + * package; for now keeping them separate avoids a cross-package build + * dependency. + */ + +import { Query } from "firebase-admin/firestore" +import { db, DocumentData, QueryDocumentSnapshot } from "../firebase" +import { embedText } from "./embeddings" +import { LLM_CONFIG } from "./config" + +// --------------------------------------------------------------------------- +// Firestore findNearest wrapper +// --------------------------------------------------------------------------- + +const VECTOR_FIELD = "vector_embedding" + +/** + * Runs a Firestore vector similarity search (COSINE) against the given + * collection/collectionGroup query. Uses the object-form findNearest API + * with distanceResultField so each returned doc carries its COSINE distance + * as a virtual field — matching the pattern used by the MCP server's + * search_policies in mcp-server/tools.ts. + * + * firebase-admin's bundled Firestore client (v7) supports this API, but + * this project's direct @google-cloud/firestore dependency is pinned at v5 + * whose typings predate it. Cast to bridge the gap (same pattern used in + * search/createVectorIndexer.ts for FieldValue.vector()). + */ +export async function findNearest( + query: Query, + embedding: number[], + limit: number +): Promise[]> { + const vectorQuery = query as unknown as { + findNearest(options: { + vectorField: string + queryVector: number[] + distanceMeasure: "COSINE" + distanceResultField: string + limit: number + }): { get(): Promise<{ docs: QueryDocumentSnapshot[] }> } + } + + const snapshot = await vectorQuery + .findNearest({ + vectorField: VECTOR_FIELD, + queryVector: embedding, + distanceMeasure: "COSINE", + distanceResultField: "distance", + limit + }) + .get() + + return snapshot.docs +} + +// --------------------------------------------------------------------------- +// Result formatters (plain text snippets for LLM consumption) +// --------------------------------------------------------------------------- + +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 +} + +export function formatBillDoc(doc: QueryDocumentSnapshot): string { + 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") +} + +export function formatBallotQuestionDoc( + doc: QueryDocumentSnapshot +): string { + 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") +} + +// --------------------------------------------------------------------------- +// Testimony formatters +// --------------------------------------------------------------------------- + +export function formatTestimonyDoc( + doc: QueryDocumentSnapshot +): string { + const data = doc.data() + const subject = data.billId + ? `bill ${data.billId} (${data.billTitle ?? "unknown title"})` + : data.ballotQuestionId + ? `ballot question ${data.ballotQuestionId}` + : "unknown policy" + return [ + `Testimony on ${subject}`, + `Author: ${data.authorDisplayName ?? "anonymous"}`, + `Position: ${data.position ?? "not stated"}`, + `Content: ${truncate(data.content)}` + ].join("\n") +} + +// --------------------------------------------------------------------------- +// Core testimony search functions +// --------------------------------------------------------------------------- + +/** + * Semantic search over testimony on bills. Optionally scope to a specific + * bill by passing billId. Mirrors the bill path of the MCP server's + * search_testimony (policyType="bill") in mcp-server/tools.ts. + */ +export async function searchBillTestimony( + query: string, + billId?: string, + topK = LLM_CONFIG.vectorSearchTopK +): Promise { + const embedding = await embedText(query) + let base: Query = db.collectionGroup("publishedTestimony") + if (billId) base = base.where("billId", "==", billId) + + const docs = await findNearest(base, embedding, topK) + if (docs.length === 0) return "No matching bill testimony found." + return docs.map(formatTestimonyDoc).join("\n\n") +} + +/** + * Semantic search over testimony on ballot questions. Optionally scope to a + * specific question by passing ballotQuestionId. Mirrors the ballot path of + * the MCP server's search_testimony (policyType="ballot") in + * mcp-server/tools.ts. + */ +export async function searchBallotQuestionTestimony( + query: string, + ballotQuestionId?: string, + topK = LLM_CONFIG.vectorSearchTopK +): Promise { + const embedding = await embedText(query) + let base: Query = db.collectionGroup("publishedTestimony") + if (ballotQuestionId) + base = base.where("ballotQuestionId", "==", ballotQuestionId) + + const docs = await findNearest(base, embedding, topK) + if (docs.length === 0) return "No matching ballot question testimony found." + return docs.map(formatTestimonyDoc).join("\n\n") +} + +/** + * Unified semantic search across all testimony (bills + ballot questions). + * Runs one collectionGroup query with no policyType filter, then formats + * each result with enough context for the LLM to tell which type it is. + * Use as the default when the question doesn't specify a policy type. + */ +export async function searchTestimony( + query: string, + topK = LLM_CONFIG.vectorSearchTopK +): Promise { + const embedding = await embedText(query) + const docs = await findNearest( + db.collectionGroup("publishedTestimony"), + embedding, + topK + ) + if (docs.length === 0) return "No matching testimony found." + return docs.map(formatTestimonyDoc).join("\n\n") +} + +/** + * Semantic search over Massachusetts legislative bills (all courts). + * Returns a formatted text block ready for LLM consumption, or a + * "no results" string. + */ +export async function searchBills( + query: string, + topK = LLM_CONFIG.vectorSearchTopK +): Promise { + const embedding = await embedText(query) + const docs = await findNearest(db.collectionGroup("bills"), embedding, topK) + + if (docs.length === 0) return "No matching bills found." + + return docs.map(formatBillDoc).join("\n\n") +} + +/** + * Semantic search over statewide ballot questions. + * Returns a formatted text block ready for LLM consumption, or a + * "no results" string. + */ +export async function searchBallotQuestions( + query: string, + topK = LLM_CONFIG.vectorSearchTopK +): Promise { + const embedding = await embedText(query) + const docs = await findNearest( + db.collection("ballotQuestions"), + embedding, + topK + ) + + if (docs.length === 0) return "No matching ballot questions found." + + return docs.map(formatBallotQuestionDoc).join("\n\n") +} + +/** + * Unified semantic search across bills AND ballot questions, sorted by + * relevance. Follows the same pattern as search_policies in + * mcp-server/tools.ts: + * 1. Run both queries in parallel with distanceResultField="distance" + * 2. Compute relevanceScore = 1 - distance on each doc + * 3. Merge and sort by relevanceScore descending — no manual interleave needed + * + * Returns a formatted text block ready for LLM consumption, or a + * "no results" string. + */ +export async function searchPolicies( + query: string, + topK = LLM_CONFIG.vectorSearchTopK +): Promise { + const embedding = await embedText(query) + + // Run both searches in parallel — same pattern as search_policies in + // mcp-server/tools.ts + const [billDocs, bqDocs] = await Promise.all([ + findNearest(db.collectionGroup("bills"), embedding, topK), + findNearest(db.collection("ballotQuestions"), embedding, topK) + ]) + + if (billDocs.length === 0 && bqDocs.length === 0) { + return "No matching bills or ballot questions found." + } + + // Compute relevanceScore = 1 - distance for each doc, matching the MCP + // server's shapeBill/shapeBallotQuestion pattern in mcp-server/tools.ts. + // distanceResultField="distance" is set in findNearest above so doc.get() + // returns the COSINE distance as a virtual field on each snapshot. + function relevanceScore(doc: QueryDocumentSnapshot): number { + const distance = (doc as any).get("distance") + return distance != null ? Math.round((1 - distance) * 1000) / 1000 : 0 + } + + type ScoredEntry = { score: number; formatted: string } + + const scoredBills: ScoredEntry[] = billDocs.map(doc => ({ + score: relevanceScore(doc), + formatted: `[Bill]\n${formatBillDoc(doc)}` + })) + + const scoredBqs: ScoredEntry[] = bqDocs.map(doc => ({ + score: relevanceScore(doc), + formatted: `[Ballot Question]\n${formatBallotQuestionDoc(doc)}` + })) + + // Merge all results and sort by relevance score descending — same approach + // as the MCP server's search_policies merge step. + return [...scoredBills, ...scoredBqs] + .sort((a, b) => b.score - a.score) + .slice(0, topK * 2) + .map(entry => entry.formatted) + .join("\n\n") +} 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..d2a14015b --- /dev/null +++ b/functions/src/llm/vectorSearchTools.ts @@ -0,0 +1,209 @@ +/** + * LangChain tool wrappers for the ReAct agent's vector search capabilities. + * + * Core Firestore search logic lives in policySearch.ts (single source of + * truth for bills + ballot questions). Each tool here is a thin wrapper that + * calls the shared helper and returns a formatted string to the LLM. + * + * The MCP server (mcp-server/tools.ts) exposes identically-named tools + * (search_bills, search_ballot_questions, search_policies) backed by the + * same Firestore collections — keep descriptions and result shapes in sync + * when changing either file. + * + * Adding a new source type (e.g. hearing transcripts): add a helper to + * policySearch.ts and register one more tool() here. + */ + +import { DynamicStructuredTool } from "@langchain/core/tools" +import { z } from "zod" +import { + searchBills, + searchBallotQuestions, + searchPolicies, + searchBillTestimony, + searchBallotQuestionTestimony, + searchTestimony +} from "./policySearch" + +// --------------------------------------------------------------------------- +// search_bills — legislative bills only +// --------------------------------------------------------------------------- + +const billsSchema = z.object({ + query: z + .string() + .describe("A natural-language description of the bill topic to search for") +}) + +export const searchBillsTool = new DynamicStructuredTool({ + name: "search_bills", + description: + "Semantic search over Massachusetts legislative bills (title and full text). " + + "Use this when the question is specifically about legislation or a bill number. " + + "For questions that may involve both bills and ballot questions, prefer search_policies.", + schema: billsSchema, + func: async (input: unknown) => { + const { query } = input as z.infer + return searchBills(query) + } +}) + +// --------------------------------------------------------------------------- +// search_ballot_questions — ballot questions only +// --------------------------------------------------------------------------- + +const ballotSchema = z.object({ + query: z + .string() + .describe( + "A natural-language description of the ballot question topic to search for" + ) +}) + +export const searchBallotQuestionsTool = new DynamicStructuredTool({ + name: "search_ballot_questions", + description: + "Semantic search over statewide ballot questions (title, description, and summary). " + + "Use this when the question is specifically about a ballot initiative or referendum. " + + "For questions that may involve both bills and ballot questions, prefer search_policies.", + schema: ballotSchema, + func: async (input: unknown) => { + const { query } = input as z.infer + return searchBallotQuestions(query) + } +}) + +// --------------------------------------------------------------------------- +// search_policies — combined bills + ballot questions, sorted by relevance +// --------------------------------------------------------------------------- + +const policiesSchema = z.object({ + query: z + .string() + .describe( + "A natural-language description of the policy topic or issue to search for" + ) +}) + +export const searchPoliciesTool = new DynamicStructuredTool({ + name: "search_policies", + description: + "Unified semantic search across both Massachusetts legislative bills AND " + + "ballot questions, ranked by relevance. Use this as the default starting point " + + "for any policy or issue question when you are unsure whether the answer lies " + + "in a bill, a ballot question, or both.", + schema: policiesSchema, + func: async (input: unknown) => { + const { query } = input as z.infer + return searchPolicies(query) + } +}) + +// --------------------------------------------------------------------------- +// search_bill_testimony — testimony on bills (optionally scoped to one bill) +// --------------------------------------------------------------------------- + +const billTestimonySchema = 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 (e.g. 'H1234') to restrict results to testimony on that specific bill" + ) +}) + +export const searchBillTestimonyTool = new DynamicStructuredTool({ + name: "search_bill_testimony", + description: + "Semantic search over testimony submitted on Massachusetts legislative bills. " + + "Optionally scope to a specific bill by providing its ID. Use this when the " + + "question is specifically about what people have said about a bill or legislation. " + + "For questions spanning both bills and ballot questions, prefer search_testimony.", + schema: billTestimonySchema, + func: async (input: unknown) => { + const { query, billId } = input as z.infer + return searchBillTestimony(query, billId) + } +}) + +// --------------------------------------------------------------------------- +// search_ballot_question_testimony — testimony on ballot questions +// --------------------------------------------------------------------------- + +const ballotQuestionTestimonySchema = z.object({ + query: z + .string() + .describe( + "A natural-language description of the testimony content to search for" + ), + ballotQuestionId: z + .string() + .optional() + .describe( + "Optional ballot question ID to restrict results to testimony on that specific question" + ) +}) + +export const searchBallotQuestionTestimonyTool = new DynamicStructuredTool({ + name: "search_ballot_question_testimony", + description: + "Semantic search over testimony submitted on Massachusetts ballot questions. " + + "Optionally scope to a specific ballot question by providing its ID. Use this " + + "when the question is specifically about what people have said about a ballot " + + "initiative or referendum. For questions spanning both bills and ballot questions, " + + "prefer search_testimony.", + schema: ballotQuestionTestimonySchema, + func: async (input: unknown) => { + const { query, ballotQuestionId } = input as z.infer< + typeof ballotQuestionTestimonySchema + > + return searchBallotQuestionTestimony(query, ballotQuestionId) + } +}) + +// --------------------------------------------------------------------------- +// search_testimony — combined testimony across bills + ballot questions +// --------------------------------------------------------------------------- + +const testimonySchema = z.object({ + query: z + .string() + .describe( + "A natural-language description of the testimony content to search for" + ) +}) + +export const searchTestimonyTool = new DynamicStructuredTool({ + name: "search_testimony", + description: + "Unified semantic search across all public testimony — covering both " + + "bills and ballot questions — ranked by relevance. Use this as the default " + + "when you are unsure whether the testimony relates to a bill or a ballot question, " + + "or when the question spans both types.", + schema: testimonySchema, + func: async (input: unknown) => { + const { query } = input as z.infer + return searchTestimony(query) + } +}) + +// --------------------------------------------------------------------------- +// Tool list registered with the ReAct agent (agent.ts) +// --------------------------------------------------------------------------- + +export const vectorSearchTools = [ + // Policy (bills + ballot questions) + searchPoliciesTool, // combined — default for policy questions + searchBillsTool, // bill-only + searchBallotQuestionsTool, // ballot-only + + // Testimony + searchTestimonyTool, // combined — default for testimony questions + searchBillTestimonyTool, // testimony on bills only + searchBallotQuestionTestimonyTool // testimony on ballot questions only +] 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..8b79f6e06 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,74 @@ 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/google-common@~0.1.8": + version "0.1.8" + resolved "https://registry.yarnpkg.com/@langchain/google-common/-/google-common-0.1.8.tgz#a8d0fb8946334675aa32f1e8501d43b3c7e4e870" + integrity sha512-8auqWw2PMPhcHQHS+nMN3tVZrUPgSLckUaFeOHDOeSBiDvBd4KCybPwyl2oCwMDGvmyIxvOOckkMdeGaJ92vpQ== + dependencies: + uuid "^10.0.0" + zod-to-json-schema "^3.22.4" + +"@langchain/google-gauth@~0.1.8": + version "0.1.8" + resolved "https://registry.yarnpkg.com/@langchain/google-gauth/-/google-gauth-0.1.8.tgz#7210cb72b42502ed744028cff09bc4690e4e2342" + integrity sha512-2QK7d5SQMrnSv7X4j05BGfO74hiA8FJuNwSsQKZvzlGoVnNXil3x2aqD5V+zsYOPpxhkDCpNlmh2Pue2Wzy1rQ== + dependencies: + "@langchain/google-common" "~0.1.8" + google-auth-library "^8.9.0" + +"@langchain/google-vertexai@^0.1.0": + version "0.1.8" + resolved "https://registry.yarnpkg.com/@langchain/google-vertexai/-/google-vertexai-0.1.8.tgz#a9b1eeb52aba3b61c0812198921dc947f7979f75" + integrity sha512-n06ohihopz38agOm7BTASHMmFLz+XAZlzEvqtPC4Qa1fhYhzETQg2gCzEapIJ1yVk5MhrWqwKnVOQ+tIsFE88Q== + dependencies: + "@langchain/google-gauth" "~0.1.8" + +"@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" + "@nodable/entities@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-2.1.0.tgz#f543e5c6446720d4cf9e498a83019dd159973bc2" @@ -1442,9 +1515,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": @@ -1554,6 +1627,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 +1679,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" @@ -1967,7 +2050,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 +2304,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 +2340,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 +2627,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 +2816,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 +3174,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" @@ -3815,7 +3915,7 @@ google-auth-library@^7.14.0: jws "^4.0.0" lru-cache "^6.0.0" -google-auth-library@^8.0.2: +google-auth-library@^8.0.2, google-auth-library@^8.9.0: version "8.9.0" resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-8.9.0.tgz" integrity sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg== @@ -4880,6 +4980,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 +5205,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" @@ -5606,9 +5725,14 @@ ms@2.1.2: ms@2.1.3, 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" @@ -5873,6 +5997,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 +6030,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 +6920,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 +7052,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 +7631,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@^4.5.5: + version "4.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" + integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== typesense@^1.2.2: version "1.7.2" @@ -7987,7 +8149,17 @@ zip-stream@^6.0.1: compress-commons "^6.0.2" readable-stream "^4.0.0" +zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.22.4: + 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.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