diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..3138a16671be5ad1ce3858261c86fadbbe2380c7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.venv +__pycache__ +*.py[cod] +node_modules +frontend/dist +data/qdrant_db +data/alphabet_10k_submission.txt +data/enriched_chunks.json +data/*.db +.env +.env.* +!.env.example +*.md +agent-transcripts +.cursor diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..1a685c055b691b19fe3e3c095f8d1dc90c093cdf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1 + +# --- frontend --------------------------------------------------------------- +FROM node:22-alpine AS frontend +WORKDIR /ui +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci || npm install +COPY frontend/ ./ +RUN npm run build + +# --- backend ---------------------------------------------------------------- +# Hugging Face Docker Spaces run as UID 1000. Install system packages as root, +# then drop privileges before baking weights or starting uvicorn. +FROM python:3.12-slim AS runtime + +RUN useradd -m -u 1000 user + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + HOME=/home/user \ + HF_HOME=/home/user/.cache/huggingface \ + TRANSFORMERS_CACHE=/home/user/.cache/huggingface \ + EMBEDDING_PROVIDER=local \ + AGENT_MODE=live \ + PORT=8080 \ + PATH=/home/user/.local/bin:$PATH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# CPU torch first so sentence-transformers does not pull a CUDA wheel. +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu + +COPY backend/requirements.txt backend/requirements.txt +RUN pip install --no-cache-dir -r backend/requirements.txt + +COPY --chown=user:user backend ./backend +COPY --chown=user:user data/alphabet_financials_structured.csv data/alphabet_financials_structured.csv +COPY --from=frontend --chown=user:user /ui/dist ./frontend/dist + +USER user + +# Bake model weights so the first Spaces request does not download HF models. +RUN python -c "from sentence_transformers import SentenceTransformer, CrossEncoder; \ +SentenceTransformer('BAAI/bge-small-en-v1.5'); \ +CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')" + +EXPOSE 8080 +CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8080}"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/agent/__init__.py b/backend/app/agent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1c031ecf2c3568c8cbf34a6b862ce139e5a6098a --- /dev/null +++ b/backend/app/agent/__init__.py @@ -0,0 +1,7 @@ +"""Agent package. + +Keep this module lightweight. Scripts that only need embeddings or Qdrant +import `backend.app.agent.embeddings` / `.resources`; a side-effect import of +the LangGraph here used to pull the whole supervisor in and crash Windows +processes that then left the embedded Qdrant lock behind. +""" diff --git a/backend/app/agent/embeddings.py b/backend/app/agent/embeddings.py new file mode 100644 index 0000000000000000000000000000000000000000..6d5225324469bb92656510307cb92efcf8098424 --- /dev/null +++ b/backend/app/agent/embeddings.py @@ -0,0 +1,94 @@ +"""Embedding providers. + +This is the one part of the Gemini migration that is not a config change. The +existing Qdrant collection holds 768-dimensional text-embedding-004 vectors. +Embeddings from a different model are not comparable to those, even at matching +dimensionality, so switching providers means recreating the collection. + +The failure mode if you switch without re-indexing is nasty: Qdrant happily +returns nearest neighbours in the wrong vector space, so retrieval degrades to +noise while every component reports success. `assert_dimension_matches` exists +to turn that into an error at startup. +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import List + +from langchain_core.embeddings import Embeddings + +from ..config import get_settings + + +# BGE retrieval models are trained asymmetrically: queries get an instruction +# prefix, passages do not. Omitting it costs real accuracy, and since the +# asymmetry is invisible at call time it is easy to get wrong in one place only. +BGE_QUERY_INSTRUCTION = "Represent this sentence for searching relevant passages: " + + +class SentenceTransformerEmbeddings(Embeddings): + """Local embeddings via sentence-transformers. + + Hand-rolled rather than pulling in langchain-huggingface, since + sentence-transformers is already a dependency for the CrossEncoder reranker. + """ + + def __init__(self, model_name: str) -> None: + from sentence_transformers import SentenceTransformer + + self._model = SentenceTransformer(model_name) + self._query_instruction = ( + BGE_QUERY_INSTRUCTION if "bge" in model_name.lower() else "" + ) + + @property + def dimension(self) -> int: + return int(self._model.get_sentence_embedding_dimension()) + + def _encode(self, texts: List[str]) -> List[List[float]]: + # Normalised vectors make cosine distance equivalent to a dot product, + # which is what the collection is configured for. + return self._model.encode( + texts, normalize_embeddings=True, show_progress_bar=False + ).tolist() + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + return self._encode(texts) + + def embed_query(self, text: str) -> List[float]: + return self._encode([self._query_instruction + text])[0] + + +@lru_cache(maxsize=1) +def get_embeddings() -> Embeddings: + settings = get_settings() + + if settings.embedding_provider == "local": + return SentenceTransformerEmbeddings(settings.local_embedding_model) + + from langchain_google_genai import GoogleGenerativeAIEmbeddings + + return GoogleGenerativeAIEmbeddings( + model="models/text-embedding-004", + google_api_key=settings.require_google_key(), + ) + + +def embedding_dimension() -> int: + embedder = get_embeddings() + if isinstance(embedder, SentenceTransformerEmbeddings): + return embedder.dimension + return len(embedder.embed_query("dimension probe")) + + +def assert_dimension_matches(collection_dimension: int) -> None: + actual = embedding_dimension() + if actual != collection_dimension: + settings = get_settings() + raise RuntimeError( + f"Embedding provider '{settings.embedding_provider}' produces {actual}-dim " + f"vectors but collection '{settings.collection_name}' expects " + f"{collection_dimension}. Re-index before querying — mismatched vector " + f"spaces return plausible nonsense rather than failing." + ) diff --git a/backend/app/agent/graph.py b/backend/app/agent/graph.py new file mode 100644 index 0000000000000000000000000000000000000000..e56a8da13c68c68c0935a4c2077ed0e9dc6d9191 --- /dev/null +++ b/backend/app/agent/graph.py @@ -0,0 +1,389 @@ +"""The LangGraph supervisor: Gatekeeper -> Planner -> Tools -> Auditor -> Synthesizer. + +Ported from `agent_tools.py` with two bug fixes and one prompt change. + +Bug 1 — the original router cleared failed work with `state['intermediate_steps'] = []` +inside a conditional-edge function. Conditional edges only choose a route; their +mutations are discarded, so rejected tool output stayed in state and reached the +Synthesizer. Clearing now happens in the Planner, which can actually write state. + +Bug 2 — nothing capped Auditor-driven replanning. If the model could not satisfy +the Auditor, planner -> tools -> auditor cycled until LangGraph raised +GraphRecursionError. `replan_count` is now checked against `max_replans`, and on +exhaustion the graph synthesises from what it has instead of looping. + +Prompt change — the Gatekeeper prompt that worked on Gemini makes Qwen2.5-7B far +too eager to ask for clarification; it rejected "What was Alphabet revenue in +FY2024?" as ambiguous. It now gets worked examples and a default-to-specific rule. +""" + +from __future__ import annotations + +import asyncio +import json +import re +from typing import Any, Dict, List, Optional, TypedDict + +from langgraph.graph import END, StateGraph +from pydantic import BaseModel, Field + +from ..config import get_settings +from .llm import chat_model, structured_model +from .tools import ALL_TOOLS, TOOL_MAP + + +class AgentState(TypedDict, total=False): + original_request: str + clarification_question: Optional[str] + plan: List[Dict[str, Any]] + intermediate_steps: List[Dict[str, Any]] + verification_history: List[Dict[str, Any]] + replan_count: int + final_response: str + + +# --- Structured output schemas ---------------------------------------------- + + +class GatekeeperDecision(BaseModel): + is_specific: bool = Field(description="True if the request is actionable as written.") + clarification_question: Optional[str] = Field( + default=None, description="Question to ask the user. Null when is_specific is true." + ) + + +class PlanStep(BaseModel): + tool_name: str = Field(description="Exact tool name, or FINISH for the final step.") + tool_input: str = Field(description="Complete input string for the tool.") + + +class Plan(BaseModel): + steps: List[PlanStep] = Field(description="Ordered tool calls. The last must be FINISH.") + + +class VerificationResult(BaseModel): + confidence_score: int = Field(ge=1, le=5, description="1-5 confidence in the tool output.") + is_relevant: bool + reasoning: str + + +# Demo corpus is the FY2024 Alphabet 10-K only. Other years are refused +# before any LLM call so they do not burn GPU or invent numbers from the +# leftover 2022–2023 SQL rows. +DEMO_SCOPE_YEAR = 2024 +DEMO_SCOPE_MESSAGE = ( + "This is a demo project with limited data. Only Alphabet's FY2024 10-K " + "is in scope, so I cannot answer questions about other years. Ask about " + "2024, for example: \"What was total revenue in FY2024?\"" +) +_YEAR_RE = re.compile(r"\b(?:fy\s*)?(20\d{2})\b", re.IGNORECASE) +_MULTI_YEAR_RE = re.compile( + r"\b(?:last|past|previous|prior)\s+" + r"(?:\d+|two|three|four|five)\s+years?\b" + r"|\bover the years\b", + re.IGNORECASE, +) + + +def demo_scope_refusal(request: str) -> Optional[str]: + """Return the demo refusal if the request names a year other than 2024.""" + years = {int(match.group(1)) for match in _YEAR_RE.finditer(request)} + if any(year != DEMO_SCOPE_YEAR for year in years) or _MULTI_YEAR_RE.search(request): + return DEMO_SCOPE_MESSAGE + return None + + +_SPECIFIC_RE = re.compile( + r"revenue|margin|risk|10-?k|income|cloud|search|capex|eps|profit|" + r"filing|segment|advertising|youtube|net income", + re.IGNORECASE, +) + + +def looks_actionable(request: str) -> bool: + """True when the request already names a metric or topic, so skip the LLM Gatekeeper.""" + return bool(_SPECIFIC_RE.search(request)) + + +# --- Prompts ---------------------------------------------------------------- + +GATEKEEPER_PROMPT = """You decide whether a financial research request is actionable. + +Default to SPECIFIC. Only ask for clarification when the request names no metric, +no topic and no time frame at all. + +SPECIFIC examples: +- "What was total revenue in FY2024?" (names a metric and a period) +- "Analyze the revenue trend and the competitive risks in the 10-K" (multi-part is still specific) +- "Compare cloud growth to search" (names segments) + +AMBIGUOUS examples: +- "How is the company?" (no metric, no topic, no period) +- "Tell me about it." + +Request: "{request}" +""" + +AUDITOR_PROMPT = """Audit whether this tool output answers the user's request. + +Score 1-5 on whether the output, on its own, is sufficient to answer the request: + 1-2 = misses the request or covers only part of a multi-part question + 3 = adequate + 4-5 = fully covers the request + +User request: {request} +Tool called: {tool_name} +Tool output: {tool_output} +""" + +SYNTHESIZER_PROMPT = """You are an expert financial analyst. Answer the user's request +using only the context below. Where you infer a causal link, label it explicitly as a +hypothesis rather than stating it as fact. If the context does not support part of the +request, say so. + +User request: +{request} + +Context: +--- +{context} +--- + +Answer:""" + + +def _planner_prompt(request: str, feedback: str) -> str: + descriptions = "\n".join( + f"- {t.name}: {(t.description or '').strip()}" for t in ALL_TOOLS + ) + return f"""Create a short plan to answer the request using the tools below. +Use tool names exactly as written. The final step must always be FINISH with an empty input. + +Use the fewest tools that can answer. One tool is enough for a single metric or a +single qualitative topic. Never call every tool. Never add a tool "just in case". +At most two tools before FINISH unless the user asked two distinct questions. + +Tools: +{descriptions} +- FINISH: signals the plan is complete. + +{feedback} +User request: {request} +""" + + +# --- Nodes ------------------------------------------------------------------ + + +async def gatekeeper_node(state: AgentState) -> Dict[str, Any]: + request = state["original_request"] + scoped = demo_scope_refusal(request) + if scoped: + return {"clarification_question": None, "final_response": scoped} + if looks_actionable(request): + return {"clarification_question": None} + try: + decision = await structured_model(GatekeeperDecision).ainvoke( + GATEKEEPER_PROMPT.format(request=request) + ) + except Exception: + # A Gatekeeper failure should not block the request; let the Planner try. + return {"clarification_question": None} + + if decision.is_specific: + return {"clarification_question": None} + return { + "clarification_question": decision.clarification_question + or "Could you be more specific about which metric or period you mean?" + } + + +async def planner_node(state: AgentState) -> Dict[str, Any]: + settings = get_settings() + request = state["original_request"] + history = state.get("verification_history") or [] + steps = state.get("intermediate_steps") or [] + + is_replan = bool(history) and history[-1]["confidence_score"] < settings.audit_pass_score + feedback = "" + if is_replan: + audit = history[-1] + failed_tool = steps[-1]["tool_name"] if steps else "unknown" + feedback = ( + "Previous attempt feedback:\n" + f"The tool '{failed_tool}' was called but the auditor scored it " + f"{audit['confidence_score']}/5, reasoning: '{audit['reasoning']}'\n" + "Produce a DIFFERENT plan that covers what was missed. Do not repeat the " + "same first step.\n" + ) + + plan = await structured_model(Plan).ainvoke(_planner_prompt(request, feedback)) + steps_out = [step.model_dump() for step in plan.steps] + + # Bound the plan so the superstep budget stays a known quantity. + working = [s for s in steps_out if s["tool_name"] != "FINISH"][: settings.max_plan_steps] + steps_out = working + [{"tool_name": "FINISH", "tool_input": ""}] + + updates: Dict[str, Any] = {"plan": steps_out} + if is_replan: + # Discard rejected work so the Synthesizer never grounds on it, and reset + # the audit trail so the router judges the new attempt on its own merits. + updates["intermediate_steps"] = [] + updates["verification_history"] = [] + updates["replan_count"] = state.get("replan_count", 0) + 1 + return updates + + +async def tool_executor_node(state: AgentState) -> Dict[str, Any]: + plan = state.get("plan") or [] + if not plan: + return {} + + step, remaining = plan[0], plan[1:] + if step["tool_name"] == "FINISH": + return {"plan": remaining} + + tool = TOOL_MAP.get(step["tool_name"]) + if tool is None: + # Hallucinated tool name. Record it as a failed step so the Auditor sees + # the problem and the Planner gets a chance to correct itself. + output: Any = f"Unknown tool '{step['tool_name']}'. Valid tools: {sorted(TOOL_MAP)}" + else: + try: + output = await tool.ainvoke({"query": step["tool_input"]}) + except Exception as exc: + output = f"Tool raised {type(exc).__name__}: {exc}" + + completed = { + "tool_name": step["tool_name"], + "tool_input": step["tool_input"], + "tool_output": output, + } + return { + "intermediate_steps": (state.get("intermediate_steps") or []) + [completed], + "plan": remaining, + } + + +async def auditor_node(state: AgentState) -> Dict[str, Any]: + steps = state.get("intermediate_steps") or [] + if not steps: + return {} + + last = steps[-1] + try: + result = await structured_model(VerificationResult).ainvoke( + AUDITOR_PROMPT.format( + request=state["original_request"], + tool_name=last["tool_name"], + # Truncated: retrieval output can be tens of KB and the model has + # a 4096-token context window. + tool_output=json.dumps(last["tool_output"], default=str)[:2500], + ) + ) + audit = result.model_dump() + except Exception as exc: + # Treat an unreadable audit as a pass. Failing closed here would replan + # on infrastructure errors, which is the wrong response. + audit = { + "confidence_score": get_settings().audit_pass_score, + "is_relevant": True, + "reasoning": f"Audit unavailable ({type(exc).__name__}); proceeding.", + } + + return {"verification_history": (state.get("verification_history") or []) + [audit]} + + +async def synthesizer_node(state: AgentState) -> Dict[str, Any]: + steps = state.get("intermediate_steps") or [] + if not steps: + return { + "final_response": ( + "I could not gather enough grounded information to answer that. " + "Try naming a specific metric and period." + ) + } + + context = "\n\n".join( + f"## Tool: {s['tool_name']}\nOutput: {json.dumps(s['tool_output'], default=str)[:3000]}" + for s in steps + ) + response = await chat_model(temperature=0.2, max_tokens=1024).ainvoke( + SYNTHESIZER_PROMPT.format(request=state["original_request"], context=context) + ) + return {"final_response": response.content} + + +# --- Routing ---------------------------------------------------------------- + + +def route_after_audit(state: AgentState) -> str: + settings = get_settings() + history = state.get("verification_history") or [] + + plan = state.get("plan") or [] + remaining = [s for s in plan if s["tool_name"] != "FINISH"] + audit_failed = bool(history) and history[-1]["confidence_score"] < settings.audit_pass_score + + if audit_failed: + if state.get("replan_count", 0) < settings.max_replans: + return "planner" + # Replan budget spent. Keep going only if untried steps remain — they may + # yet succeed. Otherwise synthesise from imperfect evidence, which beats + # burning supersteps re-auditing failures until the limit trips. + return "execute_tool" if remaining else "synthesize" + + return "execute_tool" if remaining else "synthesize" + + +def build_graph(): + builder = StateGraph(AgentState) + builder.add_node("gatekeeper", gatekeeper_node) + builder.add_node("planner", planner_node) + builder.add_node("execute_tool", tool_executor_node) + builder.add_node("verify", auditor_node) + builder.add_node("synthesize", synthesizer_node) + + builder.set_entry_point("gatekeeper") + builder.add_conditional_edges( + "gatekeeper", + lambda s: END if (s.get("clarification_question") or s.get("final_response")) else "planner", + {END: END, "planner": "planner"}, + ) + builder.add_edge("planner", "execute_tool") + builder.add_edge("execute_tool", "verify") + builder.add_conditional_edges( + "verify", + route_after_audit, + {"planner": "planner", "execute_tool": "execute_tool", "synthesize": "synthesize"}, + ) + builder.add_edge("synthesize", END) + return builder.compile() + + +_graph = None + + +def get_graph(): + global _graph + if _graph is None: + _graph = build_graph() + return _graph + + +def run_config() -> Dict[str, Any]: + return {"recursion_limit": get_settings().recursion_limit} + + +async def run_agent(query: str) -> AgentState: + """Run to completion under a hard wall-clock ceiling. + + The recursion limit bounds graph steps but not time — a single slow tool can + still hang the request, so both ceilings are needed. + """ + settings = get_settings() + initial: AgentState = {"original_request": query, "replan_count": 0} + return await asyncio.wait_for( + get_graph().ainvoke(initial, run_config()), + timeout=settings.agent_timeout_s, + ) diff --git a/backend/app/agent/llm.py b/backend/app/agent/llm.py new file mode 100644 index 0000000000000000000000000000000000000000..02ccbf96214faef1a951f69e4bd53f828d1c0ac5 --- /dev/null +++ b/backend/app/agent/llm.py @@ -0,0 +1,59 @@ +"""Chat model construction, pointed at vLLM. + +Structured output uses `method="json_schema"` rather than the library default. +Both work against vLLM 0.28, but json_schema routes through the OpenAI +`response_format` field, which vLLM honours with its constrained-decoding +backend (xgrammar). Measured 5/5 schema-valid Plan generations that way. + +Do NOT reach for `extra_body={"guided_json": ...}`. In vLLM 0.28 that key is +silently ignored — the request succeeds and returns unconstrained prose, which +is far worse than an error because it looks like a model quality problem. +""" + +from __future__ import annotations + +from typing import Type, TypeVar + +from langchain_openai import ChatOpenAI +from pydantic import BaseModel + +from ..config import get_settings + +TModel = TypeVar("TModel", bound=BaseModel) + + +def chat_model( + temperature: float = 0.0, + max_tokens: int | None = None, + *, + streaming: bool = True, +) -> ChatOpenAI: + settings = get_settings() + return ChatOpenAI( + base_url=settings.vllm_base_url, + api_key=settings.vllm_api_key, + model=settings.vllm_model, + temperature=temperature, + max_completion_tokens=max_tokens, + timeout=settings.llm_timeout_s, + max_retries=settings.llm_max_retries, + # Default 120s kills the first chunk of a cold RunPod worker. + stream_chunk_timeout=settings.llm_timeout_s, + disable_streaming=not streaming, + # httpx2's gzip/brotli decoder crashes on RunPod responses + # (`process() takes no keyword arguments`) and langchain reports that + # as a generic "Connection error." + default_headers={"Accept-Encoding": "identity"}, + ) + + +def structured_model(schema: Type[TModel], temperature: float = 0.0): + """A model constrained to emit `schema`. + + Qwen2.5-7B-AWQ is a much weaker instruction follower than the Gemini model + this replaced, so constrained decoding is load-bearing here rather than a + nicety: the Gatekeeper, Planner and Auditor all fail hard on invalid JSON. + """ + return chat_model(temperature=temperature, streaming=False).with_structured_output( + schema, method="json_schema" + ) diff --git a/backend/app/agent/resources.py b/backend/app/agent/resources.py new file mode 100644 index 0000000000000000000000000000000000000000..695061ef1a43875b7fbe35336840e4d431d04344 --- /dev/null +++ b/backend/app/agent/resources.py @@ -0,0 +1,88 @@ +"""Lazily constructed shared resources. + +The original `agent_tools.py` opened Qdrant, loaded a CrossEncoder and called +`setup_data_stores()` — which calls `exit()` on missing files — at module import +time. Importing it from a web server would kill the worker on a bad path and +hold the embedded Qdrant lock for the process lifetime. Everything here is +built on first use instead, and raises rather than exiting. +""" + +from __future__ import annotations + +from functools import lru_cache + +import qdrant_client +from qdrant_client.models import Distance, VectorParams + +from ..config import get_settings + + +@lru_cache(maxsize=1) +def get_qdrant() -> qdrant_client.QdrantClient: + settings = get_settings() + if settings.qdrant_url: + return qdrant_client.QdrantClient( + url=settings.qdrant_url, + api_key=settings.qdrant_api_key, + check_compatibility=False, + ) + if not settings.qdrant_path.exists(): + raise RuntimeError( + f"No Qdrant store at {settings.qdrant_path}. Run the ingestion scripts " + f"or set QDRANT_URL to a server." + ) + return qdrant_client.QdrantClient(path=str(settings.qdrant_path)) + + +def collection_dimension() -> int: + settings = get_settings() + info = get_qdrant().get_collection(settings.collection_name) + params = info.config.params + vectors = params.vectors + if isinstance(vectors, VectorParams): + return int(vectors.size) + if isinstance(vectors, dict): # named vectors + return int(next(iter(vectors.values())).size) + raise RuntimeError(f"Cannot determine vector size for '{settings.collection_name}'") + + +def collection_distance() -> Distance | None: + settings = get_settings() + vectors = get_qdrant().get_collection(settings.collection_name).config.params.vectors + if isinstance(vectors, VectorParams): + return vectors.distance + return None + + +@lru_cache(maxsize=1) +def get_reranker(): + from sentence_transformers import CrossEncoder + + return CrossEncoder(get_settings().reranker_model) + + +@lru_cache(maxsize=1) +def get_sql_database(): + import sqlite3 + + import pandas as pd + from langchain_community.utilities import SQLDatabase + + from ..config import PROJECT_ROOT + + settings = get_settings() + if not settings.sqlite_path.exists(): + csv_path = PROJECT_ROOT / "data" / "alphabet_financials_structured.csv" + if not csv_path.exists(): + raise RuntimeError( + f"No SQLite database at {settings.sqlite_path} and no CSV at {csv_path}" + ) + settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True) + frame = pd.read_csv(csv_path) + with sqlite3.connect(settings.sqlite_path) as conn: + frame.to_sql(settings.table_name, conn, if_exists="replace", index=False) + + return SQLDatabase.from_uri( + f"sqlite:///{settings.sqlite_path}", + include_tables=[settings.table_name], + ) diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py new file mode 100644 index 0000000000000000000000000000000000000000..ae5f260495fb185cb607b7b8abd2d4ebbfac3dfe --- /dev/null +++ b/backend/app/agent/tools.py @@ -0,0 +1,197 @@ +"""The four specialist tools, ported to the local model. + +Behaviour matches `agent_tools.py`; only the model behind each call changed. +Print statements are gone — node progress now travels over the event stream. +""" + +from __future__ import annotations + +import asyncio +import sqlite3 +from typing import Any, Dict, List + +import pandas as pd +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from ..config import get_settings +from .embeddings import assert_dimension_matches, get_embeddings +from .llm import chat_model, structured_model +from .resources import collection_dimension, get_qdrant, get_reranker, get_sql_database + +_QUERY_REWRITE = ( + 'Rewrite the following query for a semantic search system. Respond with ONLY ' + 'the rewritten query, no preamble.\nQuery: "{query}"\nRewritten:' +) + + +async def optimize_query(query: str) -> str: + """Rewrite a user query for retrieval. + + Falls back to the original query on failure: a bad rewrite should degrade + retrieval, not fail the request. + """ + try: + response = await chat_model(temperature=0.0, max_tokens=128).ainvoke( + _QUERY_REWRITE.format(query=query) + ) + rewritten = (response.content or "").strip().strip('"') + return rewritten or query + except Exception: + return query + + +@tool +async def librarian_rag_tool(query: str) -> List[Dict[str, Any]]: + """Retrieves deep, contextual information from Alphabet's financial filings.""" + settings = get_settings() + assert_dimension_matches(collection_dimension()) + + optimized = await optimize_query(query) + embedding = await asyncio.to_thread(get_embeddings().embed_query, optimized) + + hits = await asyncio.to_thread( + lambda: get_qdrant().query_points( + collection_name=settings.collection_name, + query=embedding, + limit=settings.retrieval_candidates, + with_payload=True, + ).points + ) + if not hits: + return [] + + # CrossEncoder is CPU-bound; keep it off the event loop. + pairs = [[optimized, hit.payload.get("content", "")] for hit in hits] + scores = await asyncio.to_thread(get_reranker().predict, pairs) + + ranked = sorted(zip(hits, scores), key=lambda pair: pair[1], reverse=True) + return [ + { + "source": hit.payload.get("source"), + "content": hit.payload.get("content"), + "summary": hit.payload.get("summary"), + "rerank_score": float(score), + } + for hit, score in ranked[: settings.retrieval_top_k] + ] + + +class _SqlQuery(BaseModel): + sql: str = Field(description="A single read-only SQLite SELECT statement.") + + +_SQL_PROMPT = """Write one SQLite SELECT query answering the question. + +Schema: +{schema} + +Rules: +- SELECT only. No INSERT, UPDATE, DELETE, DROP or PRAGMA. +- Use only the columns shown above. +- SQLite syntax: no YEAR(), no TOP. Use LIMIT. +- Return the query only. + +Question: {question} +""" + +# Only a bare SELECT/WITH is allowed through. The question text reaches the model +# that writes this SQL, so the generated statement is untrusted input: a prompt +# injection that talks the model into a DROP must not reach the database. +_FORBIDDEN_SQL = ( + "insert", "update", "delete", "drop", "alter", "create", + "attach", "detach", "pragma", "replace", "vacuum", +) + + +def _run_select(sql: str) -> str: + settings = get_settings() + stripped = sql.strip().rstrip(";").strip() + lowered = stripped.lower() + + if not (lowered.startswith("select") or lowered.startswith("with")): + return f"Refused: only SELECT queries are allowed, got: {stripped[:120]}" + if any(word in lowered.split() for word in _FORBIDDEN_SQL): + return f"Refused: statement contains a write operation: {stripped[:120]}" + if ";" in stripped: + return "Refused: multiple statements are not allowed." + + with sqlite3.connect(f"file:{settings.sqlite_path}?mode=ro", uri=True) as conn: + df = pd.read_sql_query(stripped, conn) + + if df.empty: + return f"Query returned no rows.\nSQL: {stripped}" + return f"SQL: {stripped}\n\n{df.to_string(index=False, max_rows=40)}" + + +@tool +async def analyst_sql_tool(query: str) -> str: + """Answers specific questions about revenue, net income, etc., from a SQL database.""" + # Replaces langchain's `create_sql_agent`, which was an agent loop costing + # several LLM round trips and was the slowest node in the graph. One + # constrained-decoding call plus a direct execution is faster, and it drops a + # dependency on the legacy langchain-community agent stack. + try: + schema = await asyncio.to_thread(get_sql_database().get_table_info) + except Exception as exc: + return f"Could not read database schema: {type(exc).__name__}: {exc}" + + try: + generated = await structured_model(_SqlQuery).ainvoke( + _SQL_PROMPT.format(schema=schema, question=query) + ) + except Exception as exc: + return f"Could not generate SQL: {type(exc).__name__}: {exc}" + + try: + return await asyncio.to_thread(_run_select, generated.sql) + except Exception as exc: + return f"SQL failed: {type(exc).__name__}: {exc}\nSQL: {generated.sql}" + + +def _trend_sync(_: str) -> str: + settings = get_settings() + with sqlite3.connect(settings.sqlite_path) as conn: + df = pd.read_sql_query( + f"SELECT * FROM {settings.table_name} ORDER BY year, quarter", conn + ) + if df.empty: + return "No rows in the financial summary table." + + df["period"] = df["year"].astype(str) + "-" + df["quarter"].astype(str) + df = df.set_index("period") + metric = "revenue_usd_billions" + + qoq = df[metric].pct_change() + yoy = df[metric].pct_change(4) + return ( + f"Analysis of {metric} from {df.index[0]} to {df.index[-1]}: " + f"${df[metric].iloc[0]:.1f}B -> ${df[metric].iloc[-1]:.1f}B. " + f"Latest QoQ {qoq.iloc[-1]:.1%}, latest YoY {yoy.iloc[-1]:.1%}." + ) + + +@tool +async def analyst_trend_tool(query: str) -> str: + """Analyzes financial trends like QoQ or YoY growth from the SQL database.""" + return await asyncio.to_thread(_trend_sync, query) + + +@tool +async def scout_web_search_tool(query: str) -> str: + """Searches the web for real-time information like stock prices or recent news.""" + from langchain_community.tools import DuckDuckGoSearchRun + + try: + return await DuckDuckGoSearchRun().arun(query) + except Exception as exc: + return f"Web search failed: {type(exc).__name__}: {exc}" + + +ALL_TOOLS = [ + librarian_rag_tool, + analyst_sql_tool, + analyst_trend_tool, + scout_web_search_tool, +] +TOOL_MAP = {t.name: t for t in ALL_TOOLS} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..6bca6bfba8f8d863e7a9df3bd700600fa5fecf2d --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,114 @@ +"""Environment-driven configuration. + +Replaces the hardcoded API key in `agent_tools.py`. Nothing in the new code +path reads a literal credential. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Literal, Optional + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=PROJECT_ROOT / ".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # --- Inference ----------------------------------------------------------- + # Points at local vLLM in development and at the RunPod endpoint in + # deployment. Nothing else changes between the two. + vllm_base_url: str = "http://localhost:8000/v1" + vllm_api_key: str = "EMPTY" # vLLM ignores it, but the client requires one. + vllm_model: str = "Qwen/Qwen2.5-7B-Instruct-AWQ" + # First token after a RunPod cold start is often 2–3 minutes. The LangChain + # stream-chunk default of 120s aborts that wait and the 300s agent ceiling + # then fires while the Planner is still on its first call. + llm_timeout_s: float = 240.0 + # The openai client retries with backoff by default, which turns one dead + # request into minutes of silence. Keep it low and let the agent's own + # wall-clock ceiling be the real backstop. + llm_max_retries: int = 1 + + # --- Embeddings ---------------------------------------------------------- + # 'google' matches the vectors already in Qdrant (text-embedding-004, 768d). + # 'local' is where this is going, but flipping it requires a full re-index — + # vectors from different models are not comparable, so a mismatched + # collection returns plausible-looking nonsense rather than an error. + embedding_provider: Literal["google", "local"] = "local" + google_api_key: Optional[str] = None + local_embedding_model: str = "BAAI/bge-small-en-v1.5" + + # --- Stores -------------------------------------------------------------- + # Embedded (path) mode is single-process and lock-protected: fine for a CLI, + # unusable behind a multi-worker server or on ephemeral container storage. + # Set qdrant_url to move to server mode. + qdrant_url: Optional[str] = None + qdrant_api_key: Optional[str] = None + qdrant_path: Path = PROJECT_ROOT / "data" / "qdrant_db" + collection_name: str = "financial_docs_alphabet" + + sqlite_path: Path = PROJECT_ROOT / "data" / "financials.db" + table_name: str = "financials_summary" + + @field_validator("qdrant_url", "qdrant_api_key", mode="before") + @classmethod + def _strip_secrets(cls, value: object) -> object: + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return value + + reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2" + retrieval_candidates: int = 20 + retrieval_top_k: int = 5 + + # Tenant tag written into every Qdrant payload. Shared reference filings are + # owned by this pseudo-tenant; per-user uploads will carry their own user_id. + # Present from the first index so enabling isolation never needs a re-index. + public_tenant: str = "public" + + # --- Agent ceilings ------------------------------------------------------ + # Auditor-driven replans, hard capped. Without this the router replans + # forever whenever the model cannot satisfy the Auditor. + max_replans: int = 2 + audit_pass_score: int = 3 + # Plans longer than this are truncated, which is what makes the superstep + # budget below a bounded quantity rather than a guess. + max_plan_steps: int = 3 + + # recursion_limit is a superstep budget, not a loop guard — max_replans is + # the loop guard. Worst case is: + # gatekeeper + (max_replans + 1) * (planner + max_plan_steps * 2) + synthesize + # which is 1 + 3 * (1 + 10) + 1 = 35 at the defaults. 50 leaves headroom. + # Setting it near the worst case is what produced GraphRecursionError before. + recursion_limit: int = 50 + agent_timeout_s: float = 600.0 + + # 'mock' replays canned traces so the UI can be developed without a GPU. + # 'live' runs the LangGraph supervisor against vLLM + Qdrant. + agent_mode: Literal["mock", "live"] = "mock" + # Load BGE + CrossEncoder during lifespan. Turn off on 512 MB hosts + # (Render Free) so uvicorn can bind before the first Librarian call. + warmup_on_start: bool = True + + def require_google_key(self) -> str: + if not self.google_api_key: + raise RuntimeError( + "embedding_provider='google' needs GOOGLE_API_KEY set. Either export it " + "or switch EMBEDDING_PROVIDER=local (which requires re-indexing Qdrant)." + ) + return self.google_api_key + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/events.py b/backend/app/events.py new file mode 100644 index 0000000000000000000000000000000000000000..4539314881e85517d77f4f2d5974d0dc2522b174 --- /dev/null +++ b/backend/app/events.py @@ -0,0 +1,196 @@ +"""Wire format for the agent event stream. + +This module is the contract between the LangGraph supervisor and any client. +The frontend mirrors these shapes in `frontend/src/lib/events.ts` — change one, +change the other. + +Two rules keep the stream usable: + +1. Every event carries `seq`. SSE gives no ordering guarantee across reconnects, + and a client that buffers out-of-order node transitions will render the graph + walk wrong. `seq` is monotonic within a run. +2. `type` is duplicated in the JSON payload as well as the SSE `event:` field. + The redundancy costs a few bytes and makes a raw `curl` transcript + self-describing. +""" + +from __future__ import annotations + +import json +import time +from enum import Enum +from typing import Any, Literal, Optional, Union + +from pydantic import BaseModel, Field + + +class NodeName(str, Enum): + """Nodes in the supervisor graph, matching `agent_tools.py`.""" + + gatekeeper = "gatekeeper" + planner = "planner" + tool_executor = "tool_executor" + auditor = "auditor" + synthesizer = "synthesizer" + + +class ToolName(str, Enum): + librarian = "librarian_rag_tool" + sql_analyst = "analyst_sql_tool" + trend_analyst = "analyst_trend_tool" + scout = "scout_web_search_tool" + + +class EventType(str, Enum): + run_start = "run_start" + node_start = "node_start" + node_end = "node_end" + plan = "plan" + tool_start = "tool_start" + tool_end = "tool_end" + audit = "audit" + clarification = "clarification" + token = "token" + run_end = "run_end" + error = "error" + heartbeat = "heartbeat" + + +class _Base(BaseModel): + seq: int + ts: float = Field(default_factory=time.time) + + +class RunStart(_Base): + type: Literal[EventType.run_start] = EventType.run_start + run_id: str + conversation_id: str + query: str + + +class NodeStart(_Base): + type: Literal[EventType.node_start] = EventType.node_start + node: NodeName + # Incremented each time a node is re-entered, so the UI can show + # "Planner (attempt 2)" after the Auditor forces a replan. + attempt: int = 1 + + +class NodeEnd(_Base): + type: Literal[EventType.node_end] = EventType.node_end + node: NodeName + attempt: int = 1 + duration_ms: int + + +class PlanStep(BaseModel): + tool_name: str + tool_input: str + + +class Plan(_Base): + """Emitted by the planner so the UI can show intent before any tool runs.""" + + type: Literal[EventType.plan] = EventType.plan + attempt: int = 1 + steps: list[PlanStep] + + +class ToolStart(_Base): + type: Literal[EventType.tool_start] = EventType.tool_start + tool_name: str + tool_input: str + + +class ToolEnd(_Base): + type: Literal[EventType.tool_end] = EventType.tool_end + tool_name: str + duration_ms: int + ok: bool = True + # Never the full tool output. Retrieval hits can be tens of kilobytes and + # the browser does not need them to render a trace. + preview: Optional[str] = None + result_count: Optional[int] = None + error: Optional[str] = None + + +class Audit(_Base): + type: Literal[EventType.audit] = EventType.audit + attempt: int = 1 + confidence_score: int = Field(ge=1, le=5) + is_relevant: bool + reasoning: str + # Mirrors the `< 3` threshold in the router. + action: Literal["accept", "replan"] + + +class Clarification(_Base): + """Terminal event when the Gatekeeper judges the request too vague.""" + + type: Literal[EventType.clarification] = EventType.clarification + question: str + + +class Token(_Base): + type: Literal[EventType.token] = EventType.token + text: str + + +class RunEnd(_Base): + type: Literal[EventType.run_end] = EventType.run_end + run_id: str + duration_ms: int + # Per-node wall time, keyed by node name. The README calls for per-node + # latency instrumentation from the start; this is where it surfaces. + node_timings_ms: dict[str, int] = Field(default_factory=dict) + llm_calls: int = 0 + output_tokens: int = 0 + replans: int = 0 + + +class Error(_Base): + type: Literal[EventType.error] = EventType.error + code: str + message: str + retryable: bool = False + + +class Heartbeat(_Base): + """Named SSE event so browsers treat the stream as active during long LLM waits. + + SSE comments (`: ping`) are dropped by some clients and do not reset + fetch timeouts; Chrome then reports `Connection error` around five minutes. + """ + + type: Literal[EventType.heartbeat] = EventType.heartbeat + + +AgentEvent = Union[ + RunStart, + NodeStart, + NodeEnd, + Plan, + ToolStart, + ToolEnd, + Audit, + Clarification, + Token, + RunEnd, + Error, + Heartbeat, +] + + +def sse(event: AgentEvent) -> str: + """Serialise one event as an SSE frame. + + `json.dumps` on `model_dump(mode="json")` rather than `model_dump_json()` + so enum members land as their string values. + """ + payload: dict[str, Any] = event.model_dump(mode="json") + return f"event: {payload['type']}\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n" + + +class ChatRequest(BaseModel): + query: str = Field(min_length=1, max_length=2000) + conversation_id: Optional[str] = None diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..443d0df11e0ffba19619eb06296bc024a0d0ca8a --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,106 @@ +"""FastAPI surface for the agent. + +`AGENT_MODE=mock` (default) replays canned traces so the UI can be developed +without a GPU. `AGENT_MODE=live` runs the LangGraph supervisor. The event +contract is the same either way; only the generator behind StreamingResponse +changes. +""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from pathlib import Path +from typing import AsyncIterator + +from fastapi import FastAPI, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from fastapi.staticfiles import StaticFiles + +from .config import get_settings +from .events import ChatRequest +from .mock_agent import replay +from .stream import live_stream + +SSE_HEADERS = { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", +} + + +def _warmup() -> None: + """Load embedding + reranker weights and open stores before the first query. + + The first Librarian call otherwise spends ~15s downloading/loading models, + which on Fargate is paid by whoever happens to ask first. + """ + from .agent.embeddings import get_embeddings + from .agent.resources import get_qdrant, get_reranker, get_sql_database + + get_embeddings() + get_reranker() + get_qdrant() + get_sql_database() + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + settings = get_settings() + if settings.agent_mode == "live" and settings.warmup_on_start: + await asyncio.to_thread(_warmup) + yield + + +app = FastAPI(title="FinRAG API", version="0.2.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/health") +async def health() -> dict[str, object]: + settings = get_settings() + return { + "status": "ok", + "agent": settings.agent_mode, + "vllm": settings.vllm_base_url, + "embeddings": settings.embedding_provider, + "qdrant": str(settings.qdrant_url or settings.qdrant_path), + } + + +@app.post("/api/chat/stream") +async def chat_stream( + body: ChatRequest, + speed: float = Query(1.0, gt=0, le=50, description="Mock replay speed. Ignored in live mode."), +) -> StreamingResponse: + """Stream agent state and answer tokens as Server-Sent Events. + + POST rather than GET because the query goes in the body and, once auth + lands, the JWT goes in an Authorization header. That rules out `EventSource` + on the client — it is GET-only and cannot set headers. + """ + settings = get_settings() + + async def stream() -> AsyncIterator[bytes]: + frames = ( + live_stream(body.query, body.conversation_id) + if settings.agent_mode == "live" + else replay(body.query, body.conversation_id, speed) + ) + async for frame in frames: + yield frame.encode("utf-8") + + return StreamingResponse(stream(), media_type="text/event-stream", headers=SSE_HEADERS) + + +_dist = Path(__file__).resolve().parents[2] / "frontend" / "dist" +if _dist.is_dir(): + app.mount("/", StaticFiles(directory=str(_dist), html=True), name="spa") diff --git a/backend/app/mock_agent.py b/backend/app/mock_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..d3052a73030ef8f37eda145a3f870700755ba93c --- /dev/null +++ b/backend/app/mock_agent.py @@ -0,0 +1,384 @@ +"""Canned replays of a supervisor run, for building the frontend without a GPU. + +Timings default to the real thing rather than something snappy. A live run is +15-30s because one query fans out into Gatekeeper -> Planner -> tools -> +Auditor -> Synthesizer, and a UI that feels fine at 2s can be unusable at 25s. +Pass `speed` to compress the replay once the layout is settled. + +Replaced wholesale by `astream_events` over the real graph later; the event +shapes it emits are the ones in `events.py`. +""" + +from __future__ import annotations + +import asyncio +import re +import uuid +from collections import defaultdict +from typing import AsyncIterator + +from .events import ( + Audit, + Clarification, + Error, + NodeEnd, + NodeName, + NodeStart, + Plan, + PlanStep, + RunEnd, + RunStart, + Token, + ToolEnd, + ToolStart, + sse, +) + +# Wall times observed per node, in seconds. The SQL analyst is slow because +# `create_sql_agent` is itself an agent loop: schema introspection, query +# drafting, then execution, so several LLM round trips. +GATEKEEPER_S = 1.2 +PLANNER_S = 2.6 +AUDITOR_S = 2.1 +TOOL_S = { + "librarian_rag_tool": 2.4, # query rewrite + embed + Qdrant + CrossEncoder + "analyst_sql_tool": 4.3, + "analyst_trend_tool": 0.4, # pure pandas, no LLM + "scout_web_search_tool": 2.7, +} + +# 40 tok/s single-stream, per the benchmark table in the README. +SECONDS_PER_TOKEN = 1 / 40 + +ACTIONABLE = re.compile( + r"\b(revenue|income|profit|margin|trend|growth|risk|competiti\w*|segment|" + r"cloud|capex|10-?k|filing|quarter|q[1-4]|fy|yoy|qoq|year|compare|" + r"guidance|operating|expense|headcount|buyback|dividend)\b", + re.I, +) +QUANTITATIVE = re.compile(r"\b(revenue|income|margin|trend|growth|yoy|qoq|quarter|q[1-4]|capex|expense)\b", re.I) +QUALITATIVE = re.compile(r"\b(risk|competiti\w*|10-?k|filing|strategy|regulat\w*|moat|threat)\b", re.I) + + +class _Run: + """Sequence numbering, timing accumulation and speed scaling for one run.""" + + def __init__(self, query: str, conversation_id: str, speed: float) -> None: + self.query = query + self.conversation_id = conversation_id + self.run_id = f"run_{uuid.uuid4().hex[:12]}" + self.speed = max(speed, 0.05) + self._seq = 0 + # Reported latencies are nominal, not measured: they describe what the + # run would cost at speed=1. Measured time would collapse under a speed + # multiplier and give the UI unrealistic numbers to lay out against. + self.nominal_ms = 0 + self.node_timings_ms: dict[str, int] = defaultdict(int) + self.llm_calls = 0 + self.output_tokens = 0 + self.replans = 0 + + def seq(self) -> int: + self._seq += 1 + return self._seq + + async def sleep(self, seconds: float) -> None: + self.nominal_ms += int(seconds * 1000) + await asyncio.sleep(seconds / self.speed) + + def node_start(self, node: NodeName, attempt: int = 1) -> str: + return sse(NodeStart(seq=self.seq(), node=node, attempt=attempt)) + + def node_end(self, node: NodeName, seconds: float, attempt: int = 1) -> str: + ms = int(seconds * 1000) + self.node_timings_ms[node.value] += ms + return sse(NodeEnd(seq=self.seq(), node=node, attempt=attempt, duration_ms=ms)) + + +def _pick_scenario(query: str) -> str: + if not ACTIONABLE.search(query) or len(query.split()) < 4: + return "clarification" + if QUANTITATIVE.search(query) and QUALITATIVE.search(query): + return "replan" + return "simple" + + +TOOL_RESULTS = { + "analyst_sql_tool": ( + "FY2024 total revenue: $350.0B (FY2023: $307.4B).", + 1, + ), + "analyst_trend_tool": ( + "revenue_usd_billions 2023-Q1 to 2024-Q4: $69.8B -> $96.5B. " + "Recent QoQ 5.4%, recent YoY 11.8%.", + 8, + ), + "librarian_rag_tool": ( + "Item 1A Risk Factors - intense competition in AI, search and cloud; " + "rapid model commoditisation; regulatory pressure in the EU.", + 5, + ), + "scout_web_search_tool": ( + "GOOGL last close $178.42, +1.2% on the session.", + 3, + ), +} + +ANSWER_REPLAN = ( + "Alphabet's revenue grew from $307.4B in FY2023 to $350.0B in FY2024, a 13.9% increase, " + "with quarterly revenue rising from $69.8B in Q1 2023 to $96.5B in Q4 2024. Growth has been " + "steady rather than accelerating: recent QoQ growth of 5.4% against YoY of 11.8% suggests the " + "annual rate is being sustained by consistent sequential gains, not a single outlier quarter.\n\n" + "The 10-K's competitive risk disclosures read as a direct commentary on that trajectory. " + "Management flags intense competition across search, cloud and AI, and specifically the risk " + "that model capability becomes commoditised. A data-grounded hypothesis: the revenue line has " + "so far absorbed those pressures because Cloud is compounding off a smaller base while Search " + "monetisation holds, but the margin story is where competition would show up first. Rising " + "capital expenditure on AI infrastructure alongside only moderate sequential revenue growth " + "is the pattern to watch.\n\n" + "Caveat: this reads growth and risk disclosure side by side. The filing does not attribute " + "any revenue movement to competitive dynamics, so the causal link is inference, not disclosure." +) + +ANSWER_SIMPLE = ( + "Alphabet reported total revenue of $350.0B for FY2024, up from $307.4B in FY2023 — " + "growth of 13.9% year over year. Quarterly revenue reached $96.5B in Q4 2024, the highest " + "in the series, continuing an unbroken sequence of sequential increases through the period.\n\n" + "Source: Alphabet Inc. FY2024 10-K, consolidated statements of income." +) + + +async def _stream_tokens(run: _Run, text: str) -> AsyncIterator[str]: + """Emit `text` in word-ish chunks at the measured single-stream rate.""" + chunks = re.findall(r"\S+\s*", text) + for chunk in chunks: + await run.sleep(SECONDS_PER_TOKEN) + run.output_tokens += 1 + yield sse(Token(seq=run.seq(), text=chunk)) + + +async def _tool_call(run: _Run, tool_name: str, tool_input: str) -> AsyncIterator[str]: + seconds = TOOL_S.get(tool_name, 1.5) + preview, count = TOOL_RESULTS.get(tool_name, ("(no result)", 0)) + yield sse(ToolStart(seq=run.seq(), tool_name=tool_name, tool_input=tool_input)) + await run.sleep(seconds) + # Timing is recorded by the caller's `node_end` for tool_executor; adding it + # here as well would double count. + yield sse( + ToolEnd( + seq=run.seq(), + tool_name=tool_name, + duration_ms=int(seconds * 1000), + ok=True, + preview=preview, + result_count=count, + ) + ) + + +async def replay(query: str, conversation_id: str | None = None, speed: float = 1.0) -> AsyncIterator[str]: + run = _Run(query, conversation_id or f"conv_{uuid.uuid4().hex[:8]}", speed) + scenario = _pick_scenario(query) + + try: + yield sse( + RunStart( + seq=run.seq(), + run_id=run.run_id, + conversation_id=run.conversation_id, + query=query, + ) + ) + + # --- Gatekeeper ------------------------------------------------------ + yield run.node_start(NodeName.gatekeeper) + await run.sleep(GATEKEEPER_S) + run.llm_calls += 1 + yield run.node_end(NodeName.gatekeeper, GATEKEEPER_S) + + if scenario == "clarification": + yield sse( + Clarification( + seq=run.seq(), + question=( + "That's a little broad for me to answer well. Are you asking about " + "revenue and margin trends, the risk factors in the latest 10-K, or " + "current market performance?" + ), + ) + ) + yield sse( + RunEnd( + seq=run.seq(), + run_id=run.run_id, + duration_ms=run.nominal_ms, + node_timings_ms=dict(run.node_timings_ms), + llm_calls=run.llm_calls, + output_tokens=run.output_tokens, + replans=run.replans, + ) + ) + return + + if scenario == "replan": + # Attempt 1: the Planner reaches for SQL alone, which cannot speak to + # the qualitative half of the question. The Auditor catches it. + yield run.node_start(NodeName.planner, attempt=1) + await run.sleep(PLANNER_S) + run.llm_calls += 1 + yield sse( + Plan( + seq=run.seq(), + attempt=1, + steps=[ + PlanStep(tool_name="analyst_sql_tool", tool_input="total revenue FY2023 and FY2024"), + PlanStep(tool_name="FINISH", tool_input=""), + ], + ) + ) + yield run.node_end(NodeName.planner, PLANNER_S, attempt=1) + + yield run.node_start(NodeName.tool_executor, attempt=1) + async for frame in _tool_call(run, "analyst_sql_tool", "total revenue FY2023 and FY2024"): + yield frame + yield run.node_end(NodeName.tool_executor, TOOL_S["analyst_sql_tool"], attempt=1) + + yield run.node_start(NodeName.auditor, attempt=1) + await run.sleep(AUDITOR_S) + run.llm_calls += 1 + yield sse( + Audit( + seq=run.seq(), + attempt=1, + confidence_score=2, + is_relevant=True, + reasoning=( + "The revenue figures are accurate but address only half the request. " + "The user also asked about competitive risks in the 10-K, and no " + "retrieval over the filing was performed." + ), + action="replan", + ) + ) + yield run.node_end(NodeName.auditor, AUDITOR_S, attempt=1) + run.replans += 1 + + # Attempt 2: replan with the Auditor's reasoning fed back in. + yield run.node_start(NodeName.planner, attempt=2) + await run.sleep(PLANNER_S) + run.llm_calls += 1 + yield sse( + Plan( + seq=run.seq(), + attempt=2, + steps=[ + PlanStep(tool_name="analyst_trend_tool", tool_input="revenue trend last two years"), + PlanStep(tool_name="librarian_rag_tool", tool_input="competitive risks in AI and cloud"), + PlanStep(tool_name="FINISH", tool_input=""), + ], + ) + ) + yield run.node_end(NodeName.planner, PLANNER_S, attempt=2) + + yield run.node_start(NodeName.tool_executor, attempt=2) + for name, tool_input in ( + ("analyst_trend_tool", "revenue trend last two years"), + ("librarian_rag_tool", "competitive risks in AI and cloud"), + ): + async for frame in _tool_call(run, name, tool_input): + yield frame + yield run.node_end( + NodeName.tool_executor, + TOOL_S["analyst_trend_tool"] + TOOL_S["librarian_rag_tool"], + attempt=2, + ) + + yield run.node_start(NodeName.auditor, attempt=2) + await run.sleep(AUDITOR_S) + run.llm_calls += 1 + yield sse( + Audit( + seq=run.seq(), + attempt=2, + confidence_score=4, + is_relevant=True, + reasoning=( + "Both halves of the request are now covered: quantitative trend data " + "and the relevant risk-factor passages. Sufficient to synthesise." + ), + action="accept", + ) + ) + yield run.node_end(NodeName.auditor, AUDITOR_S, attempt=2) + answer = ANSWER_REPLAN + else: + yield run.node_start(NodeName.planner) + await run.sleep(PLANNER_S) + run.llm_calls += 1 + yield sse( + Plan( + seq=run.seq(), + steps=[ + PlanStep(tool_name="analyst_sql_tool", tool_input=query), + PlanStep(tool_name="FINISH", tool_input=""), + ], + ) + ) + yield run.node_end(NodeName.planner, PLANNER_S) + + yield run.node_start(NodeName.tool_executor) + async for frame in _tool_call(run, "analyst_sql_tool", query): + yield frame + yield run.node_end(NodeName.tool_executor, TOOL_S["analyst_sql_tool"]) + + yield run.node_start(NodeName.auditor) + await run.sleep(AUDITOR_S) + run.llm_calls += 1 + yield sse( + Audit( + seq=run.seq(), + confidence_score=4, + is_relevant=True, + reasoning="Tool output directly answers the request with figures from the filing.", + action="accept", + ) + ) + yield run.node_end(NodeName.auditor, AUDITOR_S) + answer = ANSWER_SIMPLE + + # --- Synthesizer ----------------------------------------------------- + yield run.node_start(NodeName.synthesizer) + run.llm_calls += 1 + tokens_before = run.output_tokens + async for frame in _stream_tokens(run, answer): + yield frame + yield run.node_end( + NodeName.synthesizer, + (run.output_tokens - tokens_before) * SECONDS_PER_TOKEN, + ) + + yield sse( + RunEnd( + seq=run.seq(), + run_id=run.run_id, + duration_ms=run.nominal_ms, + node_timings_ms=dict(run.node_timings_ms), + llm_calls=run.llm_calls, + output_tokens=run.output_tokens, + replans=run.replans, + ) + ) + + except asyncio.CancelledError: + # Client hung up. Nothing to clean up in the mock, but the real graph + # will need to cancel in-flight vLLM requests here. + raise + except Exception as exc: # pragma: no cover - defensive + yield sse( + Error( + seq=run.seq(), + code="mock_failure", + message=str(exc), + retryable=True, + ) + ) diff --git a/backend/app/stream.py b/backend/app/stream.py new file mode 100644 index 0000000000000000000000000000000000000000..98cad88554782706cc75c7fd23f1b189267278c5 --- /dev/null +++ b/backend/app/stream.py @@ -0,0 +1,294 @@ +"""Translate LangGraph `astream_events` into the SSE event contract. + +The first version of this adapter *predicted* the next node after each update. +After `verify` that guess is often wrong — the router and the adapter can +disagree about whether to replan, run another tool, or synthesise. Each wrong +guess emitted a `node_start synthesizer` the UI never closed, so the trace +showed "Synthesizer attempt 8 / running" while the graph was still on tools. + +`astream_events` reports the node LangGraph actually entered, so a start is +only emitted for a real visit. +""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from typing import Any, AsyncIterator, Dict, Optional + +from .agent.graph import get_graph, run_config +from .config import get_settings +from .events import ( + Audit, + Clarification, + Error, + Heartbeat, + NodeEnd, + NodeName, + NodeStart, + Plan, + PlanStep, + RunEnd, + RunStart, + Token, + ToolEnd, + ToolStart, + sse, +) + +GRAPH_TO_UI = { + "gatekeeper": NodeName.gatekeeper, + "planner": NodeName.planner, + "execute_tool": NodeName.tool_executor, + "verify": NodeName.auditor, + "synthesize": NodeName.synthesizer, +} + +PING_S = 5.0 + + +def _preview(output: Any, width: int = 240) -> str: + text = output if isinstance(output, str) else str(output) + return " ".join(text.split())[:width] + + +def _node_name(event: Dict[str, Any]) -> Optional[str]: + """Return a supervisor node name, or None for nested LLM/tool events.""" + name = event.get("name") + node = (event.get("metadata") or {}).get("langgraph_node") + if name in GRAPH_TO_UI and node == name: + return name + return None + + +async def live_stream(query: str, conversation_id: Optional[str] = None) -> AsyncIterator[str]: + """Yield SSE frames for one live supervisor run.""" + settings = get_settings() + seq = 0 + started = time.perf_counter() + last_mark = started + deadline = started + settings.agent_timeout_s + node_timings_ms: Dict[str, int] = {} + attempts: Dict[str, int] = {} + llm_calls = 0 + output_tokens = 0 + streamed_tokens = False + replan_count = 0 + run_id = f"run_{uuid.uuid4().hex[:12]}" + conversation_id = conversation_id or f"conv_{uuid.uuid4().hex[:12]}" + + def emit(event) -> str: + nonlocal seq + seq += 1 + event.seq = seq + return sse(event) + + yield emit(RunStart(seq=0, run_id=run_id, conversation_id=conversation_id, query=query)) + + iterator = get_graph().astream_events( + {"original_request": query, "replan_count": 0}, + run_config(), + version="v2", + ).__aiter__() + + # wait_for() cancels its awaitable on timeout. Cancelling anext() closes + # the async generator, so a RunPod call that takes >PING_S would abort the + # whole graph. Shield the next event and reuse the same task across pings. + pending: Optional[asyncio.Task] = None + + try: + while True: + remaining = deadline - time.perf_counter() + if remaining <= 0: + if pending is not None: + pending.cancel() + yield emit( + Error( + seq=0, + code="timeout", + message=f"Agent exceeded its {settings.agent_timeout_s:.0f}s ceiling.", + retryable=True, + ) + ) + return + if pending is None: + pending = asyncio.ensure_future(anext(iterator)) + try: + event = await asyncio.wait_for( + asyncio.shield(pending), timeout=min(PING_S, remaining) + ) + pending = None + except asyncio.TimeoutError: + if time.perf_counter() >= deadline: + pending.cancel() + yield emit( + Error( + seq=0, + code="timeout", + message=f"Agent exceeded its {settings.agent_timeout_s:.0f}s ceiling.", + retryable=True, + ) + ) + return + yield emit(Heartbeat(seq=0)) + continue + except StopAsyncIteration: + pending = None + break + + kind = event.get("event") + graph_node = _node_name(event) + + if ( + kind == "on_chat_model_stream" + and (event.get("metadata") or {}).get("langgraph_node") == "synthesize" + ): + chunk = (event.get("data") or {}).get("chunk") + text = getattr(chunk, "content", None) or "" + if isinstance(text, list): + text = "".join( + part.get("text", "") if isinstance(part, dict) else str(part) + for part in text + ) + if text: + streamed_tokens = True + output_tokens += 1 + yield emit(Token(seq=0, text=text)) + continue + + if graph_node is None: + continue + + ui_node = GRAPH_TO_UI[graph_node] + + if kind == "on_chain_start": + attempts[graph_node] = attempts.get(graph_node, 0) + 1 + last_mark = time.perf_counter() + yield emit( + NodeStart(seq=0, node=ui_node, attempt=attempts[graph_node]) + ) + continue + + if kind != "on_chain_end": + continue + + now = time.perf_counter() + duration_ms = int((now - last_mark) * 1000) + node_timings_ms[ui_node.value] = ( + node_timings_ms.get(ui_node.value, 0) + duration_ms + ) + payload = (event.get("data") or {}).get("output") or {} + if not isinstance(payload, dict): + payload = {} + + yield emit( + NodeEnd( + seq=0, + node=ui_node, + attempt=attempts.get(graph_node, 1), + duration_ms=duration_ms, + ) + ) + + if graph_node == "gatekeeper": + llm_calls += 1 + question = payload.get("clarification_question") + if question: + yield emit(Clarification(seq=0, question=question)) + scoped = payload.get("final_response") + if scoped: + streamed_tokens = True + output_tokens = len(str(scoped).split()) + yield emit(Token(seq=0, text=str(scoped))) + + elif graph_node == "planner": + llm_calls += 1 + replan_count = int(payload.get("replan_count") or replan_count) + steps = [ + PlanStep( + tool_name=s["tool_name"], + tool_input=s.get("tool_input") or "", + ) + for s in payload.get("plan") or [] + if isinstance(s, dict) and s.get("tool_name") != "FINISH" + ] + yield emit( + Plan(seq=0, attempt=attempts.get(graph_node, 1), steps=steps) + ) + + elif graph_node == "execute_tool": + steps = payload.get("intermediate_steps") or [] + if steps: + last = steps[-1] + output = last.get("tool_output") + failed = isinstance(output, str) and ( + output.startswith("Tool raised") + or output.startswith("Unknown tool") + ) + yield emit( + ToolStart( + seq=0, + tool_name=last.get("tool_name") or "unknown", + tool_input=last.get("tool_input") or "", + ) + ) + yield emit( + ToolEnd( + seq=0, + tool_name=last.get("tool_name") or "unknown", + duration_ms=duration_ms, + ok=not failed, + preview=_preview(output), + result_count=len(output) if isinstance(output, list) else None, + error=(str(output)[:200] if failed else None), + ) + ) + + elif graph_node == "verify": + llm_calls += 1 + history = payload.get("verification_history") or [] + if history: + audit = history[-1] + score = int(audit.get("confidence_score") or 1) + action = ( + "replan" if score < settings.audit_pass_score else "accept" + ) + yield emit( + Audit( + seq=0, + attempt=attempts.get(graph_node, 1), + confidence_score=score, + is_relevant=bool(audit.get("is_relevant")), + reasoning=str(audit.get("reasoning") or ""), + action=action, + ) + ) + + elif graph_node == "synthesize": + llm_calls += 1 + if not streamed_tokens: + answer = payload.get("final_response") or "" + output_tokens = len(str(answer).split()) + if answer: + yield emit(Token(seq=0, text=str(answer))) + + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - the client needs the error on the stream + yield emit( + Error(seq=0, code=type(exc).__name__, message=str(exc)[:400], retryable=True) + ) + return + + yield emit( + RunEnd( + seq=0, + run_id=run_id, + duration_ms=int((time.perf_counter() - started) * 1000), + node_timings_ms=node_timings_ms, + llm_calls=llm_calls, + output_tokens=output_tokens, + replans=replan_count, + ) + ) diff --git a/backend/check_retrieval.py b/backend/check_retrieval.py new file mode 100644 index 0000000000000000000000000000000000000000..0d16fb319d6af4982dfe50329493856d2b5863f5 --- /dev/null +++ b/backend/check_retrieval.py @@ -0,0 +1,48 @@ +"""Vector-only retrieval check. Avoids the reranker and LLM rewrite, which +together with the embedded Qdrant client have been crashing this process +(0xC0000005) on Windows. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.app.agent.embeddings import get_embeddings # noqa: E402 +from backend.app.agent.resources import get_qdrant # noqa: E402 +from backend.app.config import get_settings # noqa: E402 + +QUERIES = [ + "What are the main competitive risks Alphabet identifies in AI and cloud?", + "antitrust lawsuits Google Search advertising", + "cybersecurity risk management and incidents", +] + + +def main() -> None: + settings = get_settings() + embedder = get_embeddings() + client = get_qdrant() + print(f"collection {settings.collection_name}: " + f"{client.get_collection(settings.collection_name).points_count} points\n") + + for query in QUERIES: + print(f"Q: {query}") + hits = client.query_points( + collection_name=settings.collection_name, + query=embedder.embed_query(query), + limit=5, + with_payload=True, + ).points + for rank, hit in enumerate(hits, 1): + payload = hit.payload or {} + section = payload.get("section") or "(none)" + summary = " ".join(str(payload.get("summary") or payload.get("content") or "").split()) + print(f" {rank}. {hit.score:.4f} [{section}] {summary[:140]}") + print() + + +if __name__ == "__main__": + main() diff --git a/backend/diagnose_corpus.py b/backend/diagnose_corpus.py new file mode 100644 index 0000000000000000000000000000000000000000..b7e9a2186bce8ccae2eb4c696fe4200ab802891d --- /dev/null +++ b/backend/diagnose_corpus.py @@ -0,0 +1,51 @@ +"""How much of the 10-K actually made it into the index? + +Retrieval for risk-factor questions kept returning business-description content, +which suggests the chunking dropped sections rather than that the embeddings are +weak. Compares indexed text against the source filing. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CHUNKS = ROOT / "data" / "enriched_chunks.json" +SOURCE = ROOT / "data" / "alphabet_10k_submission.txt" + +TERMS = [ + "risk factors", + "item 1a", + "competition", + "antitrust", + "adversely affect", + "could harm", +] + + +def main() -> None: + chunks = json.loads(CHUNKS.read_text(encoding="utf-8")) + indexed = " ".join(str(c.get("content", "")) for c in chunks) + source = SOURCE.read_text(encoding="utf-8", errors="ignore") + + print(f"source file {SOURCE.stat().st_size:,} bytes") + print(f"source text {len(source):,} chars") + print(f"indexed text {len(indexed):,} chars across {len(chunks)} chunks") + print(f"coverage {len(indexed) / len(source) * 100:.1f}% of source") + print(f"avg chunk {len(indexed) // len(chunks):,} chars") + + lower_indexed = indexed.lower() + lower_source = source.lower() + print(f"\n{'term':<20} {'indexed':>8} {'source':>8}") + for term in TERMS: + print(f"{term:<20} {lower_indexed.count(term):>8} {lower_source.count(term):>8}") + + sources = {c.get("source") for c in chunks} + print(f"\ndistinct payload sources: {sources}") + tables = sum(1 for c in chunks if c.get("is_table")) + print(f"chunks flagged as tables: {tables}/{len(chunks)}") + + +if __name__ == "__main__": + main() diff --git a/backend/diagnose_retrieval.py b/backend/diagnose_retrieval.py new file mode 100644 index 0000000000000000000000000000000000000000..86a46f424e07b45b81302433948219d9d37da556 --- /dev/null +++ b/backend/diagnose_retrieval.py @@ -0,0 +1,68 @@ +"""Is the CrossEncoder rerank helping or hurting? + +The agent's Auditor rejected the Librarian's 10-K output as irrelevant while a +direct vector query on the same topic returned excellent hits. That points at +something between the two: the LLM query rewrite, or the rerank. + +Prints raw vector order against reranked order so the two are directly +comparable. Relevant to the README's unverified "30% Top-K precision +improvement" claim. +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.app.agent.embeddings import get_embeddings # noqa: E402 +from backend.app.agent.resources import get_qdrant, get_reranker # noqa: E402 +from backend.app.agent.tools import optimize_query # noqa: E402 +from backend.app.config import get_settings # noqa: E402 + +QUERY = "What are the main competitive risks Alphabet identifies in AI and cloud?" + + +def snippet(payload, width=110): + text = payload.get("summary") or payload.get("content") or "" + section = payload.get("section") or "?" + return f"[{section}] " + " ".join(str(text).split())[:width] + + +async def main(): + settings = get_settings() + embedder = get_embeddings() + client = get_qdrant() + + rewritten = await optimize_query(QUERY) + print(f"original: {QUERY}") + print(f"rewritten: {rewritten}\n") + + for label, query_text in (("ORIGINAL", QUERY), ("REWRITTEN", rewritten)): + hits = client.query_points( + collection_name=settings.collection_name, + query=embedder.embed_query(query_text), + limit=settings.retrieval_candidates, + with_payload=True, + ).points + + print(f"=== {label} ===") + print(" vector top 5:") + for rank, hit in enumerate(hits[:5], 1): + print(f" {rank}. {hit.score:.4f} {snippet(hit.payload)}") + + pairs = [[query_text, h.payload.get("content", "")] for h in hits] + scores = get_reranker().predict(pairs) + ranked = sorted(zip(hits, scores), key=lambda p: p[1], reverse=True) + + print(" reranked top 5:") + for rank, (hit, score) in enumerate(ranked[:5], 1): + moved = hits.index(hit) + 1 + print(f" {rank}. {score:+.3f} (was #{moved}) {snippet(hit.payload)}") + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/ingest/__init__.py b/backend/ingest/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/backend/ingest/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/ingest/enrich.py b/backend/ingest/enrich.py new file mode 100644 index 0000000000000000000000000000000000000000..cabe433456adccdaada74a932939ba1c07138a00 --- /dev/null +++ b/backend/ingest/enrich.py @@ -0,0 +1,190 @@ +"""Add search metadata to a chunk corpus, using Qwen on vLLM. + +Runs as a separate process from `pipeline.py` on purpose. Doing both in one +process crashed the interpreter (0xC0000005) as soon as enrichment started: the +lxml/unstructured object graph for a 2 MB filing stays alive while async HTTP +work begins, and the combination is unstable on this platform. Splitting the +stages also means a failed enrichment run never costs the parse again. + +Enrichment is best-effort by design. `enrich_chunk.py`, which this replaces, +dropped any chunk whose API call failed: + + if enrichment_data: + all_enriched_chunks.append(...) + +Here a failure leaves the chunk in place with `enriched: false` and an +`enrich_error` note, so content is never lost to a transient error. Progress is +flushed to disk periodically for the same reason. + + python backend/ingest/enrich.py --limit 8 # sample + python backend/ingest/enrich.py # all unenriched chunks + python backend/ingest/enrich.py --redo # re-enrich everything +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import random +import sys +from pathlib import Path +from typing import Any, Dict, List + +from pydantic import BaseModel, Field + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +CORPUS = Path(__file__).resolve().parents[2] / "data" / "chunks_v2.json" +SAVE_EVERY = 25 + + +class ChunkMetadata(BaseModel): + """Enrichment fields. + + Deliberately flat, with no optional members. Qwen2.5-7B follows a small + all-required schema far more reliably under constrained decoding, and a + table's summary belongs in `summary` anyway. + """ + + summary: str = Field(description="One or two sentences describing the content.") + keywords: List[str] = Field(description="Five to seven key topics or entities.") + hypothetical_questions: List[str] = Field( + description="Three to five questions this content answers." + ) + + +# The summary is embedded alongside the content, so it must read as a statement +# about Alphabet, not about the document. Qwen's default register is +# meta-commentary ("The provided excerpt does not contain...") which pollutes the +# vector with document-description language that no user query resembles. +_NO_META = """Write about Alphabet's business itself, never about this text. +Never begin with or include phrases like "The excerpt", "This chunk", "This +table", "The provided", "The document" or "This section". State the substance +directly. Add no facts that are not present. If the content is only +administrative boilerplate, say what it covers in one short clause.""" + +PROSE_PROMPT = f"""You are indexing Alphabet's 10-K for a financial analyst's search tool. + +{_NO_META} + +Phrase the questions the way an analyst would actually type them. + +Content: +--- +{{content}} +---""" + +TABLE_PROMPT = f"""You are indexing a TABLE from Alphabet's 10-K for a financial +analyst's search tool. + +{_NO_META} + +Name the line items and periods, and state any trend in the figures, for +example: "Google Cloud revenue grew from $33.1B in 2023 to $43.2B in 2024." +Do not invent figures. + +Content: +--- +{{content}} +---""" + + +async def enrich_one(record: Dict[str, Any], model, attempts: int) -> bool: + """Fill enrichment fields in place. Returns success; never raises.""" + body = str(record.get("content") or record.get("content_html") or "") + if len(body.strip()) < 40: + record["enrich_error"] = "content too short to enrich" + return False + + template = TABLE_PROMPT if record.get("is_table") else PROSE_PROMPT + prompt = template.format(content=body[:4000]) + + for attempt in range(1, attempts + 1): + try: + result: ChunkMetadata = await model.ainvoke(prompt) + record["summary"] = result.summary.strip() + record["keywords"] = [k.strip() for k in result.keywords if k.strip()] + record["hypothetical_questions"] = [ + q.strip() for q in result.hypothetical_questions if q.strip() + ] + record["enriched"] = True + record.pop("enrich_error", None) + return True + except Exception as exc: # noqa: BLE001 - enrichment is best-effort + if attempt == attempts: + record["enrich_error"] = f"{type(exc).__name__}: {exc}"[:200] + return False + # Jittered backoff so a burst of failures does not retry in lockstep. + await asyncio.sleep(min(2**attempt, 8) + random.random()) + return False + + +async def run( + records: List[Dict[str, Any]], + targets: List[Dict[str, Any]], + path: Path, + concurrency: int, + attempts: int, +) -> None: + from backend.app.agent.llm import structured_model + + model = structured_model(ChunkMetadata, temperature=0.1) + semaphore = asyncio.Semaphore(concurrency) + lock = asyncio.Lock() + total = len(targets) + done = 0 + failed = 0 + + def save() -> None: + path.write_text(json.dumps(records, indent=2), encoding="utf-8") + + async def worker(record: Dict[str, Any]) -> None: + nonlocal done, failed + async with semaphore: + ok = await enrich_one(record, model, attempts) + async with lock: + done += 1 + if not ok: + failed += 1 + if done % SAVE_EVERY == 0 or done == total: + save() + print(f" {done}/{total} done, {failed} failed (saved)", flush=True) + + await asyncio.gather(*(worker(record) for record in targets)) + save() + print(f"\nEnriched {total - failed}/{total}.") + if failed: + print(f"{failed} chunks kept with content but no metadata (enriched=false)") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--corpus", type=Path, default=CORPUS) + parser.add_argument("--limit", type=int, help="Enrich at most N chunks") + parser.add_argument("--section", help="Only chunks from this section") + parser.add_argument("--redo", action="store_true", help="Re-enrich already-done chunks") + parser.add_argument("--concurrency", type=int, default=6) + parser.add_argument("--attempts", type=int, default=3) + args = parser.parse_args() + + records = json.loads(args.corpus.read_text(encoding="utf-8")) + targets = records if args.redo else [r for r in records if not r.get("enriched")] + if args.section: + targets = [r for r in targets if r.get("section") == args.section] + if args.limit: + targets = targets[: args.limit] + + already = sum(1 for r in records if r.get("enriched")) + print(f"corpus {args.corpus.name}: {len(records)} chunks, {already} already enriched") + print(f"to enrich {len(targets)} (concurrency {args.concurrency}, {args.attempts} attempts)") + if not targets: + print("Nothing to do.") + return 0 + + asyncio.run(run(records, targets, args.corpus, args.concurrency, args.attempts)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/ingest/extract.py b/backend/ingest/extract.py new file mode 100644 index 0000000000000000000000000000000000000000..e837db00d4d3a8da34c01844740b856f94196bcb --- /dev/null +++ b/backend/ingest/extract.py @@ -0,0 +1,159 @@ +"""Extract readable 10-K text from the inline-XBRL filing. + +The source is a single iXBRL document (Workiva-generated), not an SGML +submission wrapper. That matters for two reasons: + +1. Most of its 2.5 MB is markup and XBRL facts, so measuring index coverage + against raw file size badly understates it. `visible_text()` gives the honest + denominator. +2. `` holds the hidden fact store — thousands of tagged values plus, + often, entire narrative sections duplicated inside TextBlock facts. Feeding it + to a chunker produces duplicate and non-prose chunks, so it is stripped. +""" + +from __future__ import annotations + +import re +import warnings +from pathlib import Path +from typing import Dict, List, Tuple + +from bs4 import BeautifulSoup, Comment, XMLParsedAsHTMLWarning + +# The filing opens with an XML declaration but is HTML in practice; the HTML +# parser is what handles its malformed markup correctly. +warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_SOURCE = PROJECT_ROOT / "data" / "alphabet_10k_submission.txt" + +# Canonical 10-K part/item headings, used to locate sections in the flat text. +ITEM_PATTERNS: List[Tuple[str, str]] = [ + ("item_1_business", r"item\s*1\.?\s*business"), + ("item_1a_risk_factors", r"item\s*1a\.?\s*risk\s*factors"), + ("item_1b_unresolved", r"item\s*1b\.?\s*unresolved\s*staff\s*comments"), + ("item_1c_cybersecurity", r"item\s*1c\.?\s*cybersecurity"), + ("item_2_properties", r"item\s*2\.?\s*properties"), + ("item_3_legal", r"item\s*3\.?\s*legal\s*proceedings"), + ("item_5_market", r"item\s*5\.?\s*market\s*for"), + ("item_7_mdna", r"item\s*7\.?\s*management.s\s*discussion"), + ("item_7a_market_risk", r"item\s*7a\.?\s*quantitative"), + ("item_8_financial", r"item\s*8\.?\s*financial\s*statements"), + ("item_9a_controls", r"item\s*9a\.?\s*controls"), +] + + +def load_soup(path: Path = DEFAULT_SOURCE) -> BeautifulSoup: + raw = path.read_text(encoding="utf-8", errors="ignore") + # lxml handles the iXBRL namespaces and malformed markup far faster than + # html.parser at this size. + return BeautifulSoup(raw, "lxml") + + +# Non-rendered iXBRL infrastructure: the fact store, plus the context and unit +# definitions in ix:resources. None of it is human-readable content, and leaving +# ix:resources in place inflated the coverage denominator by ~28k chars. +XBRL_PLUMBING = ("ix:header", "header", "ix:hidden", "hidden", "ix:resources", "ix:references") + +# Rendered iXBRL tags. Their contents are part of the visible document, but +# `partition_html` does not descend into namespaced elements it does not know, +# so they must be unwrapped rather than kept. ix:continuation is the important +# one: it continues a text-block fact, and the legal-contingencies notes live +# inside it. +XBRL_INLINE_PREFIX = "ix:" + + +def strip_hidden(soup: BeautifulSoup) -> BeautifulSoup: + """Remove the XBRL fact store, scripts, styles and comments.""" + for tag_name in ("script", "style", "head"): + for tag in soup.find_all(tag_name): + tag.decompose() + + for name in XBRL_PLUMBING: + for tag in soup.find_all(name): + tag.decompose() + + for comment in soup.find_all(string=lambda s: isinstance(s, Comment)): + comment.extract() + + # Elements explicitly hidden from rendering are XBRL plumbing, not content. + for tag in soup.find_all(style=re.compile(r"display\s*:\s*none", re.I)): + tag.decompose() + + return soup + + +def visible_text(soup: BeautifulSoup) -> str: + text = soup.get_text("\n") + text = re.sub(r"[ \t\u00a0]+", " ", text) + text = re.sub(r"\n\s*\n+", "\n\n", text) + return text.strip() + + +def unwrap_inline_xbrl(soup: BeautifulSoup) -> int: + """Replace remaining ix:* tags with their children. Returns the count. + + Without this, `partition_html` silently skips everything nested inside + ix:continuation — about a third of the filing's prose, including the + Antitrust Matters and cybersecurity discussions. + """ + removed = 0 + for tag in soup.find_all(lambda t: t.name and t.name.startswith(XBRL_INLINE_PREFIX)): + tag.unwrap() + removed += 1 + return removed + + +def clean_html(path: Path = DEFAULT_SOURCE, verbose: bool = False) -> str: + """HTML with XBRL plumbing removed, suitable for `partition_html`. + + Tables are preserved — they carry the financial statements — so the chunker + can still detect and structure them. + """ + soup = strip_hidden(load_soup(path)) + unwrapped = unwrap_inline_xbrl(soup) + if verbose: + print(f" unwrapped {unwrapped:,} inline iXBRL tags") + body = soup.body or soup + return str(body) + + +def _greedy_pass(lowered: str, start: int) -> List[Tuple[str, int]]: + """Match items in canonical order, each after the previous one.""" + found: List[Tuple[str, int]] = [] + cursor = start + for key, pattern in ITEM_PATTERNS: + match = re.compile(pattern).search(lowered, cursor) + if match: + found.append((key, match.start())) + cursor = match.end() + return found + + +def find_sections(text: str) -> Dict[str, int]: + """Character offsets of each 10-K item heading, in document order. + + Neither "first match" nor "last match" works here. The table of contents + lists every item in the same order as the body, so first-match returns the + TOC; cross-references like "see Item 1A Risk Factors" appear throughout, so + last-match returns those and yields offsets that run backwards. + + Instead, sweep for ordered runs of headings and keep the run that spans the + most text. The real sections are the only run covering the whole filing — + the TOC is packed into a couple of thousand characters. + """ + lowered = text.lower() + best: List[Tuple[str, int]] = [] + best_span = -1 + start = 0 + + while start < len(lowered): + run = _greedy_pass(lowered, start) + if len(run) < 2: + break + span = run[-1][1] - run[0][1] + if span > best_span: + best_span, best = span, run + start = run[0][1] + 1 + + return dict(best) diff --git a/backend/ingest/pipeline.py b/backend/ingest/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..0de73f17ef7ba479902971ffa3ae51ebeda65985 --- /dev/null +++ b/backend/ingest/pipeline.py @@ -0,0 +1,217 @@ +"""Parse and chunk the filing into a corpus. No LLM calls. + +Stage one of two; `enrich.py` adds search metadata afterwards. They are separate +processes because running both in one crashed the interpreter (0xC0000005) when +async HTTP work began while the lxml object graph was still alive. + +Replaces the parsing half of `enrich_chunk.py`, which lost about a third of the +filing's prose and half its coverage: + +* `partition_html` never descends into ``, the inline-XBRL tag + that continues a text-block fact. The Antitrust Matters and cybersecurity + discussions live inside it. `clean_html` now unwraps those tags, taking + coverage of readable text from 68% to 99%. +* Table chunks embed flattened text, not `text_as_html`. The old pipeline stored + raw HTML as embedded content for 72 of 138 chunks, so those vectors encoded + markup. HTML is kept in `content_html` for display instead. +* Every chunk is kept. Enrichment is an overlay applied later, never a filter. + + python backend/ingest/pipeline.py +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +from unstructured.chunking.title import chunk_by_title +from unstructured.partition.html import partition_html + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from backend.ingest.extract import ( # noqa: E402 + DEFAULT_SOURCE, + ITEM_PATTERNS, + clean_html, +) + +OUTPUT_PATH = Path(__file__).resolve().parents[2] / "data" / "chunks_v2.json" +SOURCE_LABEL = "10-K/alphabet_10k_submission.txt" + +MAX_CHARACTERS = 2048 +COMBINE_UNDER = 256 +NEW_AFTER = 1800 + +# `text_as_html` alone flags 114 of 200 chunks as tables, because iXBRL wraps +# narrative prose in for page layout. The two populations separate +# cleanly when measured: layout wrappers have exactly 2 rows and a digit ratio +# near 0.001, while real financial tables have 19-46 rows and ratios of +# 0.15-0.21. Requiring both signals keeps the statements and drops the wrappers. +TABLE_MIN_ROWS = 4 +TABLE_MIN_DIGIT_RATIO = 0.02 + +# A section heading is a short standalone line. Cross-references such as "see +# Item 1A Risk Factors for further detail" match the same patterns but sit inside +# long sentences, so length is what separates the two. +MAX_HEADING_CHARS = 120 + + +def digit_ratio(text: str) -> float: + if not text: + return 0.0 + return sum(char.isdigit() for char in text) / len(text) + + +def is_real_table(html: Optional[str], text: str) -> bool: + if not html: + return False + rows = html.lower().count("= TABLE_MIN_ROWS and digit_ratio(text) >= TABLE_MIN_DIGIT_RATIO + + +def heading_section(text: str) -> Optional[str]: + """The item this text is a heading for, or None if it is not a heading.""" + stripped = " ".join(text.split()) + if not stripped or len(stripped) > MAX_HEADING_CHARS: + return None + lowered = stripped.lower() + for key, pattern in ITEM_PATTERNS: + if re.match(pattern, lowered): + return key + return None + + +def tag_elements(elements) -> Dict[str, Optional[str]]: + """Map element id -> 10-K section, by walking document order. + + This replaces searching for each chunk's text inside the flat filing. That + search kept failing because unstructured collapses whitespace, so chunks + silently inherited the previous chunk's label and Item 1A never appeared at + all. Elements arrive in document order, so tracking the current heading as we + pass it needs no matching and cannot drift. + + The table of contents lists every item before the body begins, which would + otherwise consume all ten headings immediately. It is skipped by requiring + that a heading not be immediately followed by another heading. + """ + labels: Dict[str, Optional[str]] = {} + current: Optional[str] = None + for index, element in enumerate(elements): + section = heading_section(element.text or "") + if section is not None: + following = elements[index + 1].text if index + 1 < len(elements) else "" + in_toc = heading_section(following or "") is not None + if not in_toc: + current = section + labels[str(element.id)] = current + return labels + + +def build_chunks() -> List[Dict[str, Any]]: + print(f"Reading {DEFAULT_SOURCE.name}...") + cleaned = clean_html(DEFAULT_SOURCE, verbose=True) + print(f" cleaned html {len(cleaned):,} chars") + + print("Partitioning...") + elements = partition_html(text=cleaned, infer_table_structure=True) + print(f" elements {len(elements):,}") + + labels = tag_elements(elements) + tagged = sum(1 for value in labels.values() if value) + print(f" tagged {tagged:,}/{len(elements):,} elements with a section") + + print("Chunking...") + chunks = chunk_by_title( + elements, + max_characters=MAX_CHARACTERS, + combine_text_under_n_chars=COMBINE_UNDER, + new_after_n_chars=NEW_AFTER, + include_orig_elements=True, + ) + print(f" chunks {len(chunks):,}") + + records: List[Dict[str, Any]] = [] + fallback: Optional[str] = None + resolved = 0 + for index, chunk in enumerate(chunks): + meta = chunk.metadata.to_dict() + html = meta.get("text_as_html") + text = (chunk.text or "").strip() + is_table = is_real_table(html, text) + + if not text and not html: + continue + + # Take the section from the chunk's constituent elements. Carry the last + # known section forward when a chunk has none, which mirrors how a reader + # would attribute unlabelled prose. + section: Optional[str] = None + for original in getattr(chunk.metadata, "orig_elements", None) or []: + section = labels.get(str(original.id)) + if section: + break + if section: + resolved += 1 + fallback = section + else: + section = fallback + + records.append( + { + "id": index, + "source": SOURCE_LABEL, + "section": section, + "is_table": is_table, + # Embedded content is always text. HTML is display-only. + "content": text or "", + "content_html": html if is_table else None, + "enriched": False, + "summary": None, + "keywords": [], + "hypothetical_questions": [], + } + ) + + print(f" kept {len(records):,} non-empty chunks") + print(f" sectioned {resolved:,} chunks resolved directly from elements") + return records + + +def report(records: List[Dict[str, Any]]) -> None: + tables = sum(1 for r in records if r["is_table"]) + enriched = sum(1 for r in records if r["enriched"]) + chars = sum(len(r["content"]) for r in records) + print("\nCorpus") + print(f" chunks {len(records):,} ({tables} tables, {len(records) - tables} prose)") + print(f" enriched {enriched:,}") + print(f" content chars {chars:,}") + + by_section: Dict[str, int] = {} + for record in records: + key = record["section"] or "(unlabelled)" + by_section[key] = by_section.get(key, 0) + 1 + print("\nChunks per section") + for name, count in sorted(by_section.items(), key=lambda kv: -kv[1]): + print(f" {name:<26} {count:>5}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=OUTPUT_PATH) + args = parser.parse_args() + + records = build_chunks() + report(records) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(records, indent=2), encoding="utf-8") + print(f"\nWrote {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/inspect_submission.py b/backend/inspect_submission.py new file mode 100644 index 0000000000000000000000000000000000000000..97e2a41d84a4dff410c8bc7d2266dca8d7fd2156 --- /dev/null +++ b/backend/inspect_submission.py @@ -0,0 +1,60 @@ +"""What is actually inside data/alphabet_10k_submission.txt? + +The ingestion scripts run partition_html over this whole file. It is an SEC +full-submission dissemination file: several SGML-wrapped documents including +exhibits and XBRL, not a single 10-K. This prints the document inventory so the +right one can be extracted. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[1] / "data" / "alphabet_10k_submission.txt" + +DOC_RE = re.compile(r"(.*?)", re.S | re.I) +FIELD_RE = { + name: re.compile(rf"<{name}>(.*?)\s*$", re.I | re.M) + for name in ("TYPE", "SEQUENCE", "FILENAME", "DESCRIPTION") +} + + +def main() -> None: + raw = SOURCE.read_text(encoding="utf-8", errors="ignore") + print(f"file: {SOURCE.name} {len(raw):,} chars\n") + + docs = DOC_RE.findall(raw) + if not docs: + print("No wrappers found — file may be a bare HTML document.") + print(f"starts with: {raw[:300]!r}") + return + + print(f"{len(docs)} documents inside\n") + print(f"{'#':>3} {'TYPE':<14} {'SEQ':>4} {'chars':>12} FILENAME") + for index, body in enumerate(docs, start=1): + fields = {} + for name, pattern in FIELD_RE.items(): + match = pattern.search(body) + fields[name] = match.group(1).strip() if match else "" + print( + f"{index:>3} {fields['TYPE'][:14]:<14} {fields['SEQUENCE']:>4} " + f"{len(body):>12,} {fields['FILENAME'][:40]}" + ) + + # Where does the risk-factor language actually live? + print("\nRisk-factor markers per document:") + for index, body in enumerate(docs, start=1): + lowered = body.lower() + counts = { + term: lowered.count(term) + for term in ("risk factors", "adversely affect", "antitrust", "competition") + } + if any(counts.values()): + type_match = FIELD_RE["TYPE"].search(body) + doc_type = type_match.group(1).strip() if type_match else "?" + print(f" doc {index} ({doc_type}): {counts}") + + +if __name__ == "__main__": + main() diff --git a/backend/measure_corpus.py b/backend/measure_corpus.py new file mode 100644 index 0000000000000000000000000000000000000000..f066d273c3c6521550e6b144b78da8bc4e3b4256 --- /dev/null +++ b/backend/measure_corpus.py @@ -0,0 +1,121 @@ +"""Compare an indexed corpus against the filing's actual readable text. + + python backend/measure_corpus.py # old corpus + python backend/measure_corpus.py --chunks data/chunks_v2.json + python backend/measure_corpus.py --chunks ... --gap # sample what is missing + +An earlier version of this measurement divided indexed characters by raw file +size, which is ~86% XBRL markup, and reported 7.1% coverage. Visible text is the +honest denominator. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.ingest.extract import ( # noqa: E402 + DEFAULT_SOURCE, + find_sections, + load_soup, + strip_hidden, + visible_text, +) + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +TERMS = ["risk factors", "adversely affect", "antitrust", "competition", "could harm"] + + +def normalise(text: str) -> str: + return " ".join(text.lower().split()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--chunks", type=Path, default=PROJECT_ROOT / "data" / "enriched_chunks.json") + parser.add_argument("--gap", action="store_true", help="Sample lines absent from the corpus") + args = parser.parse_args() + + print("Extracting visible text...") + text = visible_text(strip_hidden(load_soup())) + raw_size = DEFAULT_SOURCE.stat().st_size + + print(f"\nraw file {raw_size:,} chars") + print(f"visible text {len(text):,} chars ({len(text) / raw_size * 100:.1f}% of raw)") + + chunks = json.loads(args.chunks.read_text(encoding="utf-8")) + contents = [str(c.get("content") or "") for c in chunks] + indexed = " ".join(contents) + tables = sum(1 for c in chunks if c.get("is_table")) + + print(f"\ncorpus {args.chunks.name}") + print(f" chunks {len(chunks):,} ({tables} tables, {len(chunks) - tables} prose)") + print(f" content chars {len(indexed):,}") + print(f" coverage {len(indexed) / len(text) * 100:.1f}% of visible text") + + lowered_text = normalise(text) + lowered_indexed = normalise(indexed) + print(f"\n{'term':<20} {'corpus':>8} {'filing':>8} {'kept':>7}") + for term in TERMS: + found = lowered_indexed.count(term) + total = lowered_text.count(term) + pct = f"{found / total * 100:.0f}%" if total else "n/a" + print(f"{term:<20} {found:>8} {total:>8} {pct:>7}") + + if not args.gap: + return + + # Which sentences of the filing never made it into any chunk? Compare on + # normalised sentence text so whitespace and case differences do not count + # as losses. + print("\nGap analysis") + sentences = [s.strip() for s in re.split(r"\n+", text) if len(s.strip()) > 25] + missing = [s for s in sentences if normalise(s)[:60] not in lowered_indexed] + print(f" lines >25 chars {len(sentences):,}") + print(f" absent from corpus{len(missing):,} ({len(missing) / len(sentences) * 100:.1f}%)") + + # Characterise the misses rather than eyeballing them one by one. + buckets: Counter[str] = Counter() + for line in missing: + stripped = line.strip() + if re.fullmatch(r"[\d\s.,$%()\-–—]+", stripped): + buckets["numeric / table fragment"] += 1 + elif re.match(r"^(item|part)\s", stripped, re.I): + buckets["item or part heading"] += 1 + elif len(stripped) < 60: + buckets["short label or cell"] += 1 + elif stripped.count("$") > 2: + buckets["financial figures"] += 1 + else: + buckets["prose"] += 1 + print("\n composition of missing lines") + for name, count in buckets.most_common(): + print(f" {name:<26} {count:>6}") + + prose_missing = [ + line + for line in missing + if len(line) >= 60 + and line.count("$") <= 2 + and not re.fullmatch(r"[\d\s.,$%()\-–—]+", line.strip()) + and not re.match(r"^(item|part)\s", line.strip(), re.I) + ] + print(f"\n sample missing prose ({len(prose_missing)} lines)") + for line in prose_missing[:12]: + print(f" - {' '.join(line.split())[:130]}") + + sections = sorted(find_sections(text).items(), key=lambda kv: kv[1]) + print("\n item headings located (offsets should ascend; they do not,") + print(" so section labels in the corpus are approximate)") + for name, start in sections: + print(f" {name:<26} {start:>9,}") + + +if __name__ == "__main__": + main() diff --git a/backend/probe_denominator.py b/backend/probe_denominator.py new file mode 100644 index 0000000000000000000000000000000000000000..333426130a37a6289fd1aba5b233b950636ddcad --- /dev/null +++ b/backend/probe_denominator.py @@ -0,0 +1,69 @@ +"""Is 'visible text' an honest denominator, and where does the lost prose live? + +Two things to rule out before treating the 32% gap as real content loss: + +1. That `strip_hidden` actually removes the iXBRL hidden fact store. If the tag + names do not match what the parser produced, the denominator silently + includes thousands of duplicated facts and every coverage number is wrong. +2. That the missing prose is not sitting inside
elements, where + `partition_html` would route it to `text_as_html` instead of `.text`. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from bs4 import BeautifulSoup + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.ingest.extract import DEFAULT_SOURCE, load_soup, strip_hidden, visible_text # noqa: E402 + +PROBE = "Antitrust Matters" + + +def main() -> None: + soup = load_soup() + before = visible_text(soup) + print(f"visible text before strip_hidden {len(before):,}") + + # What hidden-ish tags does the parser actually expose? + print("\ntag names matching hidden/header:") + for name in ("ix:header", "header", "ix:hidden", "hidden", "ix:references", "ix:resources"): + found = soup.find_all(name) + if found: + chars = sum(len(t.get_text()) for t in found) + print(f" {name:<16} {len(found):>4} tags, {chars:>10,} chars of text") + + soup2 = strip_hidden(load_soup()) + after = visible_text(soup2) + print(f"\nvisible text after strip_hidden {len(after):,}") + print(f"removed {len(before) - len(after):,}") + + # Where does the lost passage live in the DOM? + raw = DEFAULT_SOURCE.read_text(encoding="utf-8", errors="ignore") + index = raw.find(PROBE) + print(f"\n'{PROBE}' found in raw at offset {index:,}") + if index < 0: + return + + fresh = BeautifulSoup(raw, "lxml") + node = fresh.find(string=lambda s: s and PROBE in s) + if node is None: + print(" could not locate the string as a text node") + return + + ancestors = [p.name for p in node.parents if p.name] + print(f" ancestor chain: {' < '.join(ancestors[:12])}") + print(f" inside a
? {'table' in ancestors}") + + table = next((p for p in node.parents if p.name == "table"), None) + if table is not None: + text = " ".join(table.get_text(" ").split()) + print(f" enclosing table holds {len(text):,} chars of text") + print(f" first 300: {text[:300]}") + + +if __name__ == "__main__": + main() diff --git a/backend/probe_partition.py b/backend/probe_partition.py new file mode 100644 index 0000000000000000000000000000000000000000..d7126a6059ba02305e2df8fb0127e91e664bffa5 --- /dev/null +++ b/backend/probe_partition.py @@ -0,0 +1,73 @@ +"""Locate the stage that loses content: partitioning or chunking. + +Chunking splits, it does not drop, so if element text already falls short of +visible text the loss is in `partition_html`. Also traces specific missing terms +back to their surrounding prose so the gap can be characterised rather than +guessed at. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from unstructured.chunking.title import chunk_by_title +from unstructured.partition.html import partition_html + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.ingest.extract import ( # noqa: E402 + DEFAULT_SOURCE, + clean_html, + load_soup, + strip_hidden, + visible_text, +) + +TERM = "antitrust" + + +def main() -> None: + plain = visible_text(strip_hidden(load_soup())) + cleaned = clean_html(DEFAULT_SOURCE) + + elements = partition_html(text=cleaned, infer_table_structure=True) + element_chars = sum(len((el.text or "")) for el in elements) + + chunks = chunk_by_title( + elements, max_characters=2048, combine_text_under_n_chars=256, new_after_n_chars=1800 + ) + chunk_chars = sum(len((c.text or "")) for c in chunks) + + print("Stage-by-stage character counts") + print(f" visible text {len(plain):,}") + print(f" after partition {element_chars:,} ({element_chars / len(plain) * 100:.1f}%)") + print(f" after chunking {chunk_chars:,} ({chunk_chars / len(plain) * 100:.1f}%)") + + by_type: dict[str, int] = {} + for el in elements: + name = type(el).__name__ + by_type[name] = by_type.get(name, 0) + 1 + print("\nElement types from partition") + for name, count in sorted(by_type.items(), key=lambda kv: -kv[1]): + print(f" {name:<24} {count:>5}") + + # Trace the term through each stage. + element_text = " ".join((el.text or "") for el in elements).lower() + chunk_text = " ".join((c.text or "") for c in chunks).lower() + print(f"\n'{TERM}' occurrences") + print(f" visible text {plain.lower().count(TERM)}") + print(f" after partition {element_text.count(TERM)}") + print(f" after chunking {chunk_text.count(TERM)}") + + print(f"\nContexts in visible text (first 8)") + for match in list(re.finditer(TERM, plain, re.I))[:8]: + start = max(0, match.start() - 90) + snippet = " ".join(plain[start : match.end() + 90].split()) + present = "kept" if snippet[:50].lower() in element_text else "LOST" + print(f" [{present}] ...{snippet}...") + + +if __name__ == "__main__": + main() diff --git a/backend/probe_tables.py b/backend/probe_tables.py new file mode 100644 index 0000000000000000000000000000000000000000..250c5b7b4911b8fa11420ab0b97aed469ca5cda7 --- /dev/null +++ b/backend/probe_tables.py @@ -0,0 +1,70 @@ +"""How many chunks flagged is_table are genuinely tabular? + +`text_as_html` presence is the current test, and it flags 114 of 200 chunks. +That is implausible for a 10-K: iXBRL uses
for page layout, so narrative +prose ends up wrapped in one. Digit density separates the two cleanly enough to +pick a threshold from the distribution rather than by guessing. + +Reads the saved corpus rather than re-partitioning — partitioning this filing +segfaults intermittently, and the saved records already carry text and HTML. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +DEFAULT_CHUNKS = Path(__file__).resolve().parents[1] / "data" / "chunks_v2.json" + + +def digit_ratio(text: str) -> float: + if not text: + return 0.0 + return sum(char.isdigit() for char in text) / len(text) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--chunks", type=Path, default=DEFAULT_CHUNKS) + args = parser.parse_args() + + rows = json.loads(args.chunks.read_text(encoding="utf-8")) + flagged = [] + for record in rows: + if not record.get("is_table"): + continue + text = str(record.get("content") or "") + html = str(record.get("content_html") or "") + flagged.append( + { + "id": record["id"], + "section": record.get("section"), + "ratio": digit_ratio(text), + "rows": html.lower().count("4} chunks") + + print("\nlowest digit density (prose wrapped in a layout table)") + for item in sorted(flagged, key=lambda f: f["ratio"])[:8]: + print(f" id={item['id']:>3} r={item['ratio']:.3f} tr={item['rows']:>3} {item['head']}") + + print("\nhighest digit density (real financial tables)") + for item in sorted(flagged, key=lambda f: -f["ratio"])[:8]: + print(f" id={item['id']:>3} r={item['ratio']:.3f} tr={item['rows']:>3} {item['head']}") + + for threshold in (0.02, 0.03, 0.05): + kept = sum(1 for f in flagged if f["ratio"] >= threshold) + print(f"\nthreshold {threshold:.2f} -> {kept} tables, {len(rows) - kept} prose") + + +if __name__ == "__main__": + main() diff --git a/backend/reindex.py b/backend/reindex.py new file mode 100644 index 0000000000000000000000000000000000000000..676e89b9a7d1db33a13ed5ae25fcf0b46dcbfc7a --- /dev/null +++ b/backend/reindex.py @@ -0,0 +1,198 @@ +"""Rebuild the Qdrant collection with the configured embedding provider. + + python backend/reindex.py --dry-run # report what would change + python backend/reindex.py # rebuild + python backend/reindex.py --query "..." # rebuild then sanity-check retrieval + +Why this exists: the original index holds 768-dim text-embedding-004 vectors, so +moving to a local model is not a config flip. Vectors from a different model are +not comparable to those, and Qdrant will not complain — it returns nearest +neighbours in the wrong space, so retrieval silently degrades to noise. + +Replaces the Qdrant half of `populate_db.py`, which had an API key hardcoded at +line 27 and embedded via the Gemini batch endpoint. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict, List + +from qdrant_client.models import Distance, PointStruct, VectorParams + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.app.agent.embeddings import get_embeddings, embedding_dimension # noqa: E402 +from backend.app.agent.resources import get_qdrant # noqa: E402 +from backend.app.config import PROJECT_ROOT, get_settings # noqa: E402 + +CHUNKS_PATH = PROJECT_ROOT / "data" / "chunks_v2.json" +BATCH_SIZE = 32 + + +def embedding_text(chunk: Dict[str, Any]) -> str: + """Text actually sent to the embedding model. + + Keeps `populate_db.py`'s summary + keywords + content recipe but adds the + hypothetical questions, which the enrichment step already generated and the + old pipeline then ignored. They tend to phrase the chunk the way a user + would ask for it, which is exactly what helps a query match. + """ + parts = [] + if summary := chunk.get("summary"): + parts.append(f"Summary: {summary}") + if keywords := chunk.get("keywords"): + joined = ", ".join(keywords) if isinstance(keywords, list) else str(keywords) + parts.append(f"Keywords: {joined}") + if questions := chunk.get("hypothetical_questions"): + joined = " ".join(questions) if isinstance(questions, list) else str(questions) + parts.append(f"Answers questions like: {joined}") + if table_summary := chunk.get("table_summary"): + parts.append(f"Table: {table_summary}") + parts.append(f"Content: {str(chunk.get('content', ''))[:1500]}") + return "\n".join(parts) + + +def load_chunks() -> List[Dict[str, Any]]: + if not CHUNKS_PATH.exists(): + raise SystemExit(f"No enriched chunks at {CHUNKS_PATH}. Run the enrichment step first.") + with CHUNKS_PATH.open(encoding="utf-8") as handle: + chunks = json.load(handle) + if not isinstance(chunks, list) or not chunks: + raise SystemExit(f"{CHUNKS_PATH} did not contain a non-empty list.") + return chunks + + +def describe_existing(client, collection: str) -> str: + try: + info = client.get_collection(collection) + vectors = info.config.params.vectors + size = vectors.size if isinstance(vectors, VectorParams) else "?" + return f"{info.points_count} points, {size}-dim" + except Exception: + return "does not exist" + + +def reset_collection(client, dimension: int): + """Drop and recreate the collection at `dimension`, returning a usable client. + + In embedded (path) mode, delete_collection followed by create_collection does + not reliably resize the on-disk vector array — upserting then fails with + "could not broadcast input array from shape (384,) into shape (768,)". The + reliable reset there is to close the client and remove the storage directory. + Safe to do because the store is fully rebuildable from chunks_v2.json. + """ + settings = get_settings() + vectors_config = VectorParams(size=dimension, distance=Distance.COSINE) + + if settings.qdrant_url: + if client.collection_exists(settings.collection_name): + client.delete_collection(settings.collection_name) + client.create_collection(settings.collection_name, vectors_config=vectors_config) + return client + + import shutil + + client.close() + get_qdrant.cache_clear() + if settings.qdrant_path.exists(): + shutil.rmtree(settings.qdrant_path) + print(f" wiped {settings.qdrant_path}") + settings.qdrant_path.mkdir(parents=True, exist_ok=True) + + fresh = get_qdrant() + fresh.create_collection(settings.collection_name, vectors_config=vectors_config) + + actual = fresh.get_collection(settings.collection_name).config.params.vectors.size + if actual != dimension: + raise SystemExit(f"Collection created at {actual}-dim, expected {dimension}") + return fresh + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dry-run", action="store_true", help="Report without writing") + parser.add_argument("--query", help="Run a retrieval check after rebuilding") + args = parser.parse_args() + + settings = get_settings() + chunks = load_chunks() + client = get_qdrant() + + print("Re-index plan") + print(f" chunks {len(chunks)} from {CHUNKS_PATH.name}") + print(f" provider {settings.embedding_provider}") + if settings.embedding_provider == "local": + print(f" model {settings.local_embedding_model}") + print(f" target {settings.qdrant_url or settings.qdrant_path}") + print(f" collection {settings.collection_name} ({describe_existing(client, settings.collection_name)})") + + print("\nLoading embedding model...") + embedder = get_embeddings() + dimension = embedding_dimension() + print(f" new vectors {dimension}-dim") + + if args.dry_run: + print("\nDry run: nothing written.") + return 0 + + print(f"\nRecreating collection '{settings.collection_name}'...") + client = reset_collection(client, dimension) + + print(f"Embedding {len(chunks)} chunks...") + texts = [embedding_text(chunk) for chunk in chunks] + vectors: List[List[float]] = [] + for start in range(0, len(texts), BATCH_SIZE): + batch = texts[start : start + BATCH_SIZE] + vectors.extend(embedder.embed_documents(batch)) + print(f" {min(start + BATCH_SIZE, len(texts))}/{len(texts)}") + + if len(vectors) != len(chunks): + raise SystemExit(f"Got {len(vectors)} vectors for {len(chunks)} chunks; aborting.") + + points = [ + PointStruct( + id=index, + vector=vector, + payload={ + "source": chunk.get("source"), + "content": chunk.get("content"), + "summary": chunk.get("summary"), + "keywords": chunk.get("keywords"), + "is_table": chunk.get("is_table", False), + "section": chunk.get("section"), + "enriched": chunk.get("enriched", False), + # Written now so enabling per-user filtering later is a query + # change rather than another full re-index. + "tenant": settings.public_tenant, + }, + ) + for index, (chunk, vector) in enumerate(zip(chunks, vectors)) + ] + + print("Upserting...") + client.upsert(collection_name=settings.collection_name, points=points, wait=True) + count = client.get_collection(settings.collection_name).points_count + print(f"Done: {count} points, {dimension}-dim, cosine.") + + if args.query: + print(f"\nRetrieval check: {args.query!r}") + hits = client.query_points( + collection_name=settings.collection_name, + query=embedder.embed_query(args.query), + limit=3, + with_payload=True, + ).points + for rank, hit in enumerate(hits, start=1): + snippet = str(hit.payload.get("summary") or hit.payload.get("content")) + snippet = " ".join(snippet.split())[:150] + print(f" {rank}. score={hit.score:.4f} {snippet}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..457fdb77d7b4ba82250cd3e99d08d2342ae364a1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,18 @@ +# API +fastapi>=0.115 +uvicorn[standard]>=0.30 +pydantic>=2.9 +pydantic-settings>=2.5 + +# Agent +langchain-core>=0.3,<1.0 +langchain-openai>=0.3 +langgraph>=0.2 +langchain-community>=0.3 +duckduckgo-search>=6.0 + +# Retrieval +qdrant-client>=1.12 +sentence-transformers>=3.0 +pandas>=2.2 +httpx>=0.27 diff --git a/backend/run_agent.py b/backend/run_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..42edb6bb5f6c41c4c7034f17d872f15cb6e96fbc --- /dev/null +++ b/backend/run_agent.py @@ -0,0 +1,155 @@ +"""Run the migrated agent against vLLM from the command line. + + python backend/run_agent.py "What was total revenue in FY2024?" + python backend/run_agent.py --check # config + connectivity only + python backend/run_agent.py --nodes "..." # per-node timing + +Exists so the migration can be verified without the HTTP layer in the way. +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.app.agent.graph import get_graph, run_config # noqa: E402 +from backend.app.config import get_settings # noqa: E402 + + +async def check() -> int: + settings = get_settings() + print("Configuration") + print(f" vLLM {settings.vllm_base_url}") + print(f" model {settings.vllm_model}") + print(f" embeddings {settings.embedding_provider}") + print(f" qdrant {settings.qdrant_url or settings.qdrant_path}") + print(f" collection {settings.collection_name}") + print(f" max_replans {settings.max_replans}") + print(f" recursion_limit {settings.recursion_limit}") + + failures = 0 + + print("\nConnectivity") + try: + from backend.app.agent.llm import chat_model + + t0 = time.perf_counter() + reply = await chat_model(max_tokens=8).ainvoke("Reply with: ok") + print(f" [OK] vLLM chat ({time.perf_counter() - t0:.2f}s) -> {reply.content!r}") + except Exception as exc: + print(f" [FAIL] vLLM chat: {type(exc).__name__}: {exc}") + failures += 1 + + try: + from backend.app.agent.resources import collection_dimension + + dim = collection_dimension() + print(f" [OK] Qdrant collection, {dim}-dim vectors") + except Exception as exc: + print(f" [FAIL] Qdrant: {type(exc).__name__}: {exc}") + failures += 1 + dim = None + + try: + from backend.app.agent.embeddings import embedding_dimension + + emb_dim = embedding_dimension() + if dim is not None and emb_dim != dim: + print(f" [FAIL] embeddings are {emb_dim}-dim but collection is {dim}-dim") + failures += 1 + else: + print(f" [OK] embeddings, {emb_dim}-dim") + except Exception as exc: + print(f" [FAIL] embeddings: {type(exc).__name__}: {exc}") + failures += 1 + + try: + from backend.app.agent.resources import get_sql_database + + tables = get_sql_database().get_usable_table_names() + print(f" [OK] SQLite tables: {tables}") + except Exception as exc: + print(f" [FAIL] SQLite: {type(exc).__name__}: {exc}") + failures += 1 + + print("\n" + ("all checks passed" if failures == 0 else f"{failures} check(s) failed")) + return 1 if failures else 0 + + +async def stream_nodes(query: str) -> int: + """Walk the graph, printing each node as it completes with its wall time.""" + settings = get_settings() + print(f"Query: {query}\n") + + state = {"original_request": query, "replan_count": 0} + started = time.perf_counter() + last = started + timings: dict[str, float] = {} + final = None + + try: + async for update in get_graph().astream(state, run_config()): + now = time.perf_counter() + for node, payload in update.items(): + elapsed = now - last + timings[node] = timings.get(node, 0.0) + elapsed + print(f" {node:<14} {elapsed:>6.2f}s", end="") + + if node == "planner" and payload.get("plan"): + names = [s["tool_name"] for s in payload["plan"]] + replans = payload.get("replan_count") + suffix = f" (replan #{replans})" if replans else "" + print(f" plan={names}{suffix}") + elif node == "execute_tool" and payload.get("intermediate_steps"): + step = payload["intermediate_steps"][-1] + preview = str(step["tool_output"]).replace("\n", " ")[:90] + print(f" {step['tool_name']} -> {preview}") + elif node == "verify" and payload.get("verification_history"): + audit = payload["verification_history"][-1] + verdict = ( + "REPLAN" + if audit["confidence_score"] < settings.audit_pass_score + else "accept" + ) + print(f" score={audit['confidence_score']}/5 {verdict}") + elif node == "gatekeeper": + q = payload.get("clarification_question") + print(f" {'CLARIFY: ' + q[:70] if q else 'specific'}") + else: + print() + + if payload.get("final_response"): + final = payload["final_response"] + if payload.get("clarification_question"): + final = "[clarification] " + payload["clarification_question"] + last = now + except asyncio.TimeoutError: + print("\nAgent exceeded its wall-clock ceiling.") + return 1 + + total = time.perf_counter() - started + print(f"\n{'-' * 70}\n{final}\n{'-' * 70}") + print(f"total {total:.2f}s") + for node, seconds in sorted(timings.items(), key=lambda kv: -kv[1]): + print(f" {node:<14} {seconds:>6.2f}s {seconds / total * 100:>4.0f}%") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("query", nargs="?", help="Question to ask the agent") + parser.add_argument("--check", action="store_true", help="Config and connectivity only") + args = parser.parse_args() + + if args.check or not args.query: + return asyncio.run(check()) + return asyncio.run(stream_nodes(args.query)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/smoke_live.py b/backend/smoke_live.py new file mode 100644 index 0000000000000000000000000000000000000000..286e9dcf87b95e1116505f67e00a72df3a99456c --- /dev/null +++ b/backend/smoke_live.py @@ -0,0 +1,48 @@ +"""Smoke-test the live SSE adapter against local vLLM. Prints event types only.""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.app.stream import live_stream # noqa: E402 + + +async def main() -> int: + query = "What was Alphabet total revenue in FY2024?" + print(f"query: {query}\n") + async for frame in live_stream(query): + if frame.startswith(":"): + print(" ping") + continue + for line in frame.splitlines(): + if line.startswith("data: "): + payload = json.loads(line[6:]) + kind = payload.get("type") + extra = "" + if kind == "node_start": + extra = f" {payload.get('node')}" + elif kind == "node_end": + extra = f" {payload.get('node')} {payload.get('duration_ms')}ms" + elif kind == "plan": + extra = " " + ",".join(s["tool_name"] for s in payload.get("steps", [])) + elif kind == "tool_start": + extra = f" {payload.get('tool_name')}" + elif kind == "audit": + extra = f" {payload.get('confidence_score')}/5 {payload.get('action')}" + elif kind == "token": + extra = f" {payload.get('text')[:40]!r}" + elif kind == "error": + extra = f" {payload.get('code')}: {payload.get('message')}" + elif kind == "run_end": + extra = f" {payload.get('duration_ms')}ms replans={payload.get('replans')}" + print(f" {kind}{extra}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/data/alphabet_financials_structured.csv b/data/alphabet_financials_structured.csv new file mode 100644 index 0000000000000000000000000000000000000000..804299282c2a40556dabb332c2bd7c85d490616c --- /dev/null +++ b/data/alphabet_financials_structured.csv @@ -0,0 +1,9 @@ +year,quarter,revenue_usd_billions,net_income_usd_billions +2023,Q4,86.31,20.69 +2023,Q3,76.69,19.69 +2023,Q2,74.6,18.37 +2023,Q1,69.79,15.05 +2022,Q4,76.05,13.62 +2022,Q3,69.09,13.91 +2022,Q2,69.69,16.0 +2022,Q1,68.01,16.44 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a4a071156f054065358e9b09a5d8730d5b589566 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + FinRAG — Agentic Financial Analysis + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..12896d809c3b4ea91e669ae1061fbef17a51d65b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2363 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.1.11", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwindcss": "^4.1.11" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.3", + "vite": "^7.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.415", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..9dd4c278eb5d553c6f2185c4f4cea8da35d6798c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.11", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwindcss": "^4.1.11" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.3", + "vite": "^7.1.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e935d37f619743efb0ffe93b7f3c1108c2221845 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,226 @@ +import { useEffect, useRef, useState } from 'react' + +import { AgentTrace } from './components/AgentTrace' +import { LatencyBreakdown } from './components/LatencyBreakdown' +import { useAgentStream } from './lib/useAgentStream' + +const EXAMPLES = [ + 'Analyze Alphabet FY2024 revenue and how it relates to the competitive risks in the 10-K', + 'What was total revenue in FY2024?', + 'How is the company doing?', +] + +const SPEEDS = [ + { label: '1×', value: 1, hint: 'Real latency (~22s)' }, + { label: '5×', value: 5, hint: 'Compressed' }, + { label: '20×', value: 20, hint: 'Fast iteration' }, +] + +export default function App() { + const { state, send, cancel } = useAgentStream() + const [draft, setDraft] = useState(EXAMPLES[0]) + const [speed, setSpeed] = useState(5) + const [agentMode, setAgentMode] = useState<'live' | 'mock'>('mock') + const answerRef = useRef(null) + const streaming = state.status === 'streaming' + + useEffect(() => { + void fetch('/api/health') + .then((res) => res.json()) + .then((body: { agent?: string }) => { + if (body.agent === 'live') setAgentMode('live') + }) + .catch(() => undefined) + }, []) + + useEffect(() => { + answerRef.current?.scrollTo({ top: answerRef.current.scrollHeight }) + }, [state.answer]) + + function submit() { + const query = draft.trim() + if (query && !streaming) void send(query, speed) + } + + return ( +
+
+
+

+ FinRAG + + Agentic analysis of Alphabet Inc. + +

+
+
+ + {agentMode === 'live' ? 'live · RunPod' : 'mock agent'} + + {agentMode === 'mock' && ( +
+ {SPEEDS.map((option) => ( + + ))} +
+ )} +
+
+ +
+ {/* --- Conversation ------------------------------------------------ */} +
+
+ {state.query && ( +
+

{state.query}

+
+ )} + +
+ {state.status === 'idle' && ( +
+

+ Ask a question about Alphabet's filings. The supervisor graph plans, calls + tools, audits its own output, and replans when the audit fails. +

+
+ {EXAMPLES.map((example) => ( + + ))} +
+
+ )} + + {state.clarification && ( +
+

+ Gatekeeper needs clarification +

+

+ {state.clarification} +

+
+ )} + + {state.answer && ( +
+ {state.answer.split('\n\n').map((para, i) => ( +

+ {para} + {streaming && i === state.answer.split('\n\n').length - 1 && ( + + )} +

+ ))} +
+ )} + + {streaming && !state.answer && !state.clarification && ( +

+ Working — first question after idle can take a few minutes while + the GPU worker starts. Watch the trace panel. +

+ )} + + {state.errorMessage && ( +
+

+ Stream failed +

+

{state.errorMessage}

+
+ )} +
+
+ + {/* --- Composer -------------------------------------------------- */} +
+