Upload FinRAG source for Render Docker build
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +16 -0
- Dockerfile +54 -0
- backend/__init__.py +1 -0
- backend/app/__init__.py +1 -0
- backend/app/agent/__init__.py +7 -0
- backend/app/agent/embeddings.py +94 -0
- backend/app/agent/graph.py +389 -0
- backend/app/agent/llm.py +59 -0
- backend/app/agent/resources.py +88 -0
- backend/app/agent/tools.py +197 -0
- backend/app/config.py +114 -0
- backend/app/events.py +196 -0
- backend/app/main.py +106 -0
- backend/app/mock_agent.py +384 -0
- backend/app/stream.py +294 -0
- backend/check_retrieval.py +48 -0
- backend/diagnose_corpus.py +51 -0
- backend/diagnose_retrieval.py +68 -0
- backend/ingest/__init__.py +1 -0
- backend/ingest/enrich.py +190 -0
- backend/ingest/extract.py +159 -0
- backend/ingest/pipeline.py +217 -0
- backend/inspect_submission.py +60 -0
- backend/measure_corpus.py +121 -0
- backend/probe_denominator.py +69 -0
- backend/probe_partition.py +73 -0
- backend/probe_tables.py +70 -0
- backend/reindex.py +198 -0
- backend/requirements.txt +18 -0
- backend/run_agent.py +155 -0
- backend/smoke_live.py +48 -0
- data/alphabet_financials_structured.csv +9 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +2363 -0
- frontend/package.json +26 -0
- frontend/src/App.tsx +226 -0
- frontend/src/assets/hero.png +0 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/assets/vite.svg +1 -0
- frontend/src/components/AgentTrace.tsx +177 -0
- frontend/src/components/LatencyBreakdown.tsx +78 -0
- frontend/src/index.css +49 -0
- frontend/src/lib/events.ts +140 -0
- frontend/src/lib/sse.ts +55 -0
- frontend/src/lib/useAgentStream.ts +290 -0
- frontend/src/main.tsx +10 -0
- frontend/tsconfig.app.json +26 -0
- frontend/tsconfig.json +7 -0
- frontend/tsconfig.node.json +23 -0
- frontend/vite.config.ts +18 -0
.dockerignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.venv
|
| 3 |
+
__pycache__
|
| 4 |
+
*.py[cod]
|
| 5 |
+
node_modules
|
| 6 |
+
frontend/dist
|
| 7 |
+
data/qdrant_db
|
| 8 |
+
data/alphabet_10k_submission.txt
|
| 9 |
+
data/enriched_chunks.json
|
| 10 |
+
data/*.db
|
| 11 |
+
.env
|
| 12 |
+
.env.*
|
| 13 |
+
!.env.example
|
| 14 |
+
*.md
|
| 15 |
+
agent-transcripts
|
| 16 |
+
.cursor
|
Dockerfile
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
|
| 3 |
+
# --- frontend ---------------------------------------------------------------
|
| 4 |
+
FROM node:22-alpine AS frontend
|
| 5 |
+
WORKDIR /ui
|
| 6 |
+
COPY frontend/package.json frontend/package-lock.json* ./
|
| 7 |
+
RUN npm ci || npm install
|
| 8 |
+
COPY frontend/ ./
|
| 9 |
+
RUN npm run build
|
| 10 |
+
|
| 11 |
+
# --- backend ----------------------------------------------------------------
|
| 12 |
+
# Hugging Face Docker Spaces run as UID 1000. Install system packages as root,
|
| 13 |
+
# then drop privileges before baking weights or starting uvicorn.
|
| 14 |
+
FROM python:3.12-slim AS runtime
|
| 15 |
+
|
| 16 |
+
RUN useradd -m -u 1000 user
|
| 17 |
+
|
| 18 |
+
WORKDIR /app
|
| 19 |
+
|
| 20 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 21 |
+
PYTHONUNBUFFERED=1 \
|
| 22 |
+
PYTHONPATH=/app \
|
| 23 |
+
HOME=/home/user \
|
| 24 |
+
HF_HOME=/home/user/.cache/huggingface \
|
| 25 |
+
TRANSFORMERS_CACHE=/home/user/.cache/huggingface \
|
| 26 |
+
EMBEDDING_PROVIDER=local \
|
| 27 |
+
AGENT_MODE=live \
|
| 28 |
+
PORT=8080 \
|
| 29 |
+
PATH=/home/user/.local/bin:$PATH
|
| 30 |
+
|
| 31 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 32 |
+
build-essential \
|
| 33 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 34 |
+
|
| 35 |
+
# CPU torch first so sentence-transformers does not pull a CUDA wheel.
|
| 36 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 37 |
+
&& pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
| 38 |
+
|
| 39 |
+
COPY backend/requirements.txt backend/requirements.txt
|
| 40 |
+
RUN pip install --no-cache-dir -r backend/requirements.txt
|
| 41 |
+
|
| 42 |
+
COPY --chown=user:user backend ./backend
|
| 43 |
+
COPY --chown=user:user data/alphabet_financials_structured.csv data/alphabet_financials_structured.csv
|
| 44 |
+
COPY --from=frontend --chown=user:user /ui/dist ./frontend/dist
|
| 45 |
+
|
| 46 |
+
USER user
|
| 47 |
+
|
| 48 |
+
# Bake model weights so the first Spaces request does not download HF models.
|
| 49 |
+
RUN python -c "from sentence_transformers import SentenceTransformer, CrossEncoder; \
|
| 50 |
+
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
|
| 51 |
+
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')"
|
| 52 |
+
|
| 53 |
+
EXPOSE 8080
|
| 54 |
+
CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8080}"]
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
backend/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
backend/app/agent/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent package.
|
| 2 |
+
|
| 3 |
+
Keep this module lightweight. Scripts that only need embeddings or Qdrant
|
| 4 |
+
import `backend.app.agent.embeddings` / `.resources`; a side-effect import of
|
| 5 |
+
the LangGraph here used to pull the whole supervisor in and crash Windows
|
| 6 |
+
processes that then left the embedded Qdrant lock behind.
|
| 7 |
+
"""
|
backend/app/agent/embeddings.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embedding providers.
|
| 2 |
+
|
| 3 |
+
This is the one part of the Gemini migration that is not a config change. The
|
| 4 |
+
existing Qdrant collection holds 768-dimensional text-embedding-004 vectors.
|
| 5 |
+
Embeddings from a different model are not comparable to those, even at matching
|
| 6 |
+
dimensionality, so switching providers means recreating the collection.
|
| 7 |
+
|
| 8 |
+
The failure mode if you switch without re-indexing is nasty: Qdrant happily
|
| 9 |
+
returns nearest neighbours in the wrong vector space, so retrieval degrades to
|
| 10 |
+
noise while every component reports success. `assert_dimension_matches` exists
|
| 11 |
+
to turn that into an error at startup.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from functools import lru_cache
|
| 17 |
+
from typing import List
|
| 18 |
+
|
| 19 |
+
from langchain_core.embeddings import Embeddings
|
| 20 |
+
|
| 21 |
+
from ..config import get_settings
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# BGE retrieval models are trained asymmetrically: queries get an instruction
|
| 25 |
+
# prefix, passages do not. Omitting it costs real accuracy, and since the
|
| 26 |
+
# asymmetry is invisible at call time it is easy to get wrong in one place only.
|
| 27 |
+
BGE_QUERY_INSTRUCTION = "Represent this sentence for searching relevant passages: "
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class SentenceTransformerEmbeddings(Embeddings):
|
| 31 |
+
"""Local embeddings via sentence-transformers.
|
| 32 |
+
|
| 33 |
+
Hand-rolled rather than pulling in langchain-huggingface, since
|
| 34 |
+
sentence-transformers is already a dependency for the CrossEncoder reranker.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, model_name: str) -> None:
|
| 38 |
+
from sentence_transformers import SentenceTransformer
|
| 39 |
+
|
| 40 |
+
self._model = SentenceTransformer(model_name)
|
| 41 |
+
self._query_instruction = (
|
| 42 |
+
BGE_QUERY_INSTRUCTION if "bge" in model_name.lower() else ""
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def dimension(self) -> int:
|
| 47 |
+
return int(self._model.get_sentence_embedding_dimension())
|
| 48 |
+
|
| 49 |
+
def _encode(self, texts: List[str]) -> List[List[float]]:
|
| 50 |
+
# Normalised vectors make cosine distance equivalent to a dot product,
|
| 51 |
+
# which is what the collection is configured for.
|
| 52 |
+
return self._model.encode(
|
| 53 |
+
texts, normalize_embeddings=True, show_progress_bar=False
|
| 54 |
+
).tolist()
|
| 55 |
+
|
| 56 |
+
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
| 57 |
+
return self._encode(texts)
|
| 58 |
+
|
| 59 |
+
def embed_query(self, text: str) -> List[float]:
|
| 60 |
+
return self._encode([self._query_instruction + text])[0]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@lru_cache(maxsize=1)
|
| 64 |
+
def get_embeddings() -> Embeddings:
|
| 65 |
+
settings = get_settings()
|
| 66 |
+
|
| 67 |
+
if settings.embedding_provider == "local":
|
| 68 |
+
return SentenceTransformerEmbeddings(settings.local_embedding_model)
|
| 69 |
+
|
| 70 |
+
from langchain_google_genai import GoogleGenerativeAIEmbeddings
|
| 71 |
+
|
| 72 |
+
return GoogleGenerativeAIEmbeddings(
|
| 73 |
+
model="models/text-embedding-004",
|
| 74 |
+
google_api_key=settings.require_google_key(),
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def embedding_dimension() -> int:
|
| 79 |
+
embedder = get_embeddings()
|
| 80 |
+
if isinstance(embedder, SentenceTransformerEmbeddings):
|
| 81 |
+
return embedder.dimension
|
| 82 |
+
return len(embedder.embed_query("dimension probe"))
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def assert_dimension_matches(collection_dimension: int) -> None:
|
| 86 |
+
actual = embedding_dimension()
|
| 87 |
+
if actual != collection_dimension:
|
| 88 |
+
settings = get_settings()
|
| 89 |
+
raise RuntimeError(
|
| 90 |
+
f"Embedding provider '{settings.embedding_provider}' produces {actual}-dim "
|
| 91 |
+
f"vectors but collection '{settings.collection_name}' expects "
|
| 92 |
+
f"{collection_dimension}. Re-index before querying — mismatched vector "
|
| 93 |
+
f"spaces return plausible nonsense rather than failing."
|
| 94 |
+
)
|
backend/app/agent/graph.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The LangGraph supervisor: Gatekeeper -> Planner -> Tools -> Auditor -> Synthesizer.
|
| 2 |
+
|
| 3 |
+
Ported from `agent_tools.py` with two bug fixes and one prompt change.
|
| 4 |
+
|
| 5 |
+
Bug 1 — the original router cleared failed work with `state['intermediate_steps'] = []`
|
| 6 |
+
inside a conditional-edge function. Conditional edges only choose a route; their
|
| 7 |
+
mutations are discarded, so rejected tool output stayed in state and reached the
|
| 8 |
+
Synthesizer. Clearing now happens in the Planner, which can actually write state.
|
| 9 |
+
|
| 10 |
+
Bug 2 — nothing capped Auditor-driven replanning. If the model could not satisfy
|
| 11 |
+
the Auditor, planner -> tools -> auditor cycled until LangGraph raised
|
| 12 |
+
GraphRecursionError. `replan_count` is now checked against `max_replans`, and on
|
| 13 |
+
exhaustion the graph synthesises from what it has instead of looping.
|
| 14 |
+
|
| 15 |
+
Prompt change — the Gatekeeper prompt that worked on Gemini makes Qwen2.5-7B far
|
| 16 |
+
too eager to ask for clarification; it rejected "What was Alphabet revenue in
|
| 17 |
+
FY2024?" as ambiguous. It now gets worked examples and a default-to-specific rule.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import json
|
| 24 |
+
import re
|
| 25 |
+
from typing import Any, Dict, List, Optional, TypedDict
|
| 26 |
+
|
| 27 |
+
from langgraph.graph import END, StateGraph
|
| 28 |
+
from pydantic import BaseModel, Field
|
| 29 |
+
|
| 30 |
+
from ..config import get_settings
|
| 31 |
+
from .llm import chat_model, structured_model
|
| 32 |
+
from .tools import ALL_TOOLS, TOOL_MAP
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class AgentState(TypedDict, total=False):
|
| 36 |
+
original_request: str
|
| 37 |
+
clarification_question: Optional[str]
|
| 38 |
+
plan: List[Dict[str, Any]]
|
| 39 |
+
intermediate_steps: List[Dict[str, Any]]
|
| 40 |
+
verification_history: List[Dict[str, Any]]
|
| 41 |
+
replan_count: int
|
| 42 |
+
final_response: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# --- Structured output schemas ----------------------------------------------
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class GatekeeperDecision(BaseModel):
|
| 49 |
+
is_specific: bool = Field(description="True if the request is actionable as written.")
|
| 50 |
+
clarification_question: Optional[str] = Field(
|
| 51 |
+
default=None, description="Question to ask the user. Null when is_specific is true."
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class PlanStep(BaseModel):
|
| 56 |
+
tool_name: str = Field(description="Exact tool name, or FINISH for the final step.")
|
| 57 |
+
tool_input: str = Field(description="Complete input string for the tool.")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Plan(BaseModel):
|
| 61 |
+
steps: List[PlanStep] = Field(description="Ordered tool calls. The last must be FINISH.")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class VerificationResult(BaseModel):
|
| 65 |
+
confidence_score: int = Field(ge=1, le=5, description="1-5 confidence in the tool output.")
|
| 66 |
+
is_relevant: bool
|
| 67 |
+
reasoning: str
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# Demo corpus is the FY2024 Alphabet 10-K only. Other years are refused
|
| 71 |
+
# before any LLM call so they do not burn GPU or invent numbers from the
|
| 72 |
+
# leftover 2022–2023 SQL rows.
|
| 73 |
+
DEMO_SCOPE_YEAR = 2024
|
| 74 |
+
DEMO_SCOPE_MESSAGE = (
|
| 75 |
+
"This is a demo project with limited data. Only Alphabet's FY2024 10-K "
|
| 76 |
+
"is in scope, so I cannot answer questions about other years. Ask about "
|
| 77 |
+
"2024, for example: \"What was total revenue in FY2024?\""
|
| 78 |
+
)
|
| 79 |
+
_YEAR_RE = re.compile(r"\b(?:fy\s*)?(20\d{2})\b", re.IGNORECASE)
|
| 80 |
+
_MULTI_YEAR_RE = re.compile(
|
| 81 |
+
r"\b(?:last|past|previous|prior)\s+"
|
| 82 |
+
r"(?:\d+|two|three|four|five)\s+years?\b"
|
| 83 |
+
r"|\bover the years\b",
|
| 84 |
+
re.IGNORECASE,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def demo_scope_refusal(request: str) -> Optional[str]:
|
| 89 |
+
"""Return the demo refusal if the request names a year other than 2024."""
|
| 90 |
+
years = {int(match.group(1)) for match in _YEAR_RE.finditer(request)}
|
| 91 |
+
if any(year != DEMO_SCOPE_YEAR for year in years) or _MULTI_YEAR_RE.search(request):
|
| 92 |
+
return DEMO_SCOPE_MESSAGE
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
_SPECIFIC_RE = re.compile(
|
| 97 |
+
r"revenue|margin|risk|10-?k|income|cloud|search|capex|eps|profit|"
|
| 98 |
+
r"filing|segment|advertising|youtube|net income",
|
| 99 |
+
re.IGNORECASE,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def looks_actionable(request: str) -> bool:
|
| 104 |
+
"""True when the request already names a metric or topic, so skip the LLM Gatekeeper."""
|
| 105 |
+
return bool(_SPECIFIC_RE.search(request))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# --- Prompts ----------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
GATEKEEPER_PROMPT = """You decide whether a financial research request is actionable.
|
| 111 |
+
|
| 112 |
+
Default to SPECIFIC. Only ask for clarification when the request names no metric,
|
| 113 |
+
no topic and no time frame at all.
|
| 114 |
+
|
| 115 |
+
SPECIFIC examples:
|
| 116 |
+
- "What was total revenue in FY2024?" (names a metric and a period)
|
| 117 |
+
- "Analyze the revenue trend and the competitive risks in the 10-K" (multi-part is still specific)
|
| 118 |
+
- "Compare cloud growth to search" (names segments)
|
| 119 |
+
|
| 120 |
+
AMBIGUOUS examples:
|
| 121 |
+
- "How is the company?" (no metric, no topic, no period)
|
| 122 |
+
- "Tell me about it."
|
| 123 |
+
|
| 124 |
+
Request: "{request}"
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
AUDITOR_PROMPT = """Audit whether this tool output answers the user's request.
|
| 128 |
+
|
| 129 |
+
Score 1-5 on whether the output, on its own, is sufficient to answer the request:
|
| 130 |
+
1-2 = misses the request or covers only part of a multi-part question
|
| 131 |
+
3 = adequate
|
| 132 |
+
4-5 = fully covers the request
|
| 133 |
+
|
| 134 |
+
User request: {request}
|
| 135 |
+
Tool called: {tool_name}
|
| 136 |
+
Tool output: {tool_output}
|
| 137 |
+
"""
|
| 138 |
+
|
| 139 |
+
SYNTHESIZER_PROMPT = """You are an expert financial analyst. Answer the user's request
|
| 140 |
+
using only the context below. Where you infer a causal link, label it explicitly as a
|
| 141 |
+
hypothesis rather than stating it as fact. If the context does not support part of the
|
| 142 |
+
request, say so.
|
| 143 |
+
|
| 144 |
+
User request:
|
| 145 |
+
{request}
|
| 146 |
+
|
| 147 |
+
Context:
|
| 148 |
+
---
|
| 149 |
+
{context}
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
Answer:"""
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _planner_prompt(request: str, feedback: str) -> str:
|
| 156 |
+
descriptions = "\n".join(
|
| 157 |
+
f"- {t.name}: {(t.description or '').strip()}" for t in ALL_TOOLS
|
| 158 |
+
)
|
| 159 |
+
return f"""Create a short plan to answer the request using the tools below.
|
| 160 |
+
Use tool names exactly as written. The final step must always be FINISH with an empty input.
|
| 161 |
+
|
| 162 |
+
Use the fewest tools that can answer. One tool is enough for a single metric or a
|
| 163 |
+
single qualitative topic. Never call every tool. Never add a tool "just in case".
|
| 164 |
+
At most two tools before FINISH unless the user asked two distinct questions.
|
| 165 |
+
|
| 166 |
+
Tools:
|
| 167 |
+
{descriptions}
|
| 168 |
+
- FINISH: signals the plan is complete.
|
| 169 |
+
|
| 170 |
+
{feedback}
|
| 171 |
+
User request: {request}
|
| 172 |
+
"""
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# --- Nodes ------------------------------------------------------------------
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
async def gatekeeper_node(state: AgentState) -> Dict[str, Any]:
|
| 179 |
+
request = state["original_request"]
|
| 180 |
+
scoped = demo_scope_refusal(request)
|
| 181 |
+
if scoped:
|
| 182 |
+
return {"clarification_question": None, "final_response": scoped}
|
| 183 |
+
if looks_actionable(request):
|
| 184 |
+
return {"clarification_question": None}
|
| 185 |
+
try:
|
| 186 |
+
decision = await structured_model(GatekeeperDecision).ainvoke(
|
| 187 |
+
GATEKEEPER_PROMPT.format(request=request)
|
| 188 |
+
)
|
| 189 |
+
except Exception:
|
| 190 |
+
# A Gatekeeper failure should not block the request; let the Planner try.
|
| 191 |
+
return {"clarification_question": None}
|
| 192 |
+
|
| 193 |
+
if decision.is_specific:
|
| 194 |
+
return {"clarification_question": None}
|
| 195 |
+
return {
|
| 196 |
+
"clarification_question": decision.clarification_question
|
| 197 |
+
or "Could you be more specific about which metric or period you mean?"
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
async def planner_node(state: AgentState) -> Dict[str, Any]:
|
| 202 |
+
settings = get_settings()
|
| 203 |
+
request = state["original_request"]
|
| 204 |
+
history = state.get("verification_history") or []
|
| 205 |
+
steps = state.get("intermediate_steps") or []
|
| 206 |
+
|
| 207 |
+
is_replan = bool(history) and history[-1]["confidence_score"] < settings.audit_pass_score
|
| 208 |
+
feedback = ""
|
| 209 |
+
if is_replan:
|
| 210 |
+
audit = history[-1]
|
| 211 |
+
failed_tool = steps[-1]["tool_name"] if steps else "unknown"
|
| 212 |
+
feedback = (
|
| 213 |
+
"Previous attempt feedback:\n"
|
| 214 |
+
f"The tool '{failed_tool}' was called but the auditor scored it "
|
| 215 |
+
f"{audit['confidence_score']}/5, reasoning: '{audit['reasoning']}'\n"
|
| 216 |
+
"Produce a DIFFERENT plan that covers what was missed. Do not repeat the "
|
| 217 |
+
"same first step.\n"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
plan = await structured_model(Plan).ainvoke(_planner_prompt(request, feedback))
|
| 221 |
+
steps_out = [step.model_dump() for step in plan.steps]
|
| 222 |
+
|
| 223 |
+
# Bound the plan so the superstep budget stays a known quantity.
|
| 224 |
+
working = [s for s in steps_out if s["tool_name"] != "FINISH"][: settings.max_plan_steps]
|
| 225 |
+
steps_out = working + [{"tool_name": "FINISH", "tool_input": ""}]
|
| 226 |
+
|
| 227 |
+
updates: Dict[str, Any] = {"plan": steps_out}
|
| 228 |
+
if is_replan:
|
| 229 |
+
# Discard rejected work so the Synthesizer never grounds on it, and reset
|
| 230 |
+
# the audit trail so the router judges the new attempt on its own merits.
|
| 231 |
+
updates["intermediate_steps"] = []
|
| 232 |
+
updates["verification_history"] = []
|
| 233 |
+
updates["replan_count"] = state.get("replan_count", 0) + 1
|
| 234 |
+
return updates
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
async def tool_executor_node(state: AgentState) -> Dict[str, Any]:
|
| 238 |
+
plan = state.get("plan") or []
|
| 239 |
+
if not plan:
|
| 240 |
+
return {}
|
| 241 |
+
|
| 242 |
+
step, remaining = plan[0], plan[1:]
|
| 243 |
+
if step["tool_name"] == "FINISH":
|
| 244 |
+
return {"plan": remaining}
|
| 245 |
+
|
| 246 |
+
tool = TOOL_MAP.get(step["tool_name"])
|
| 247 |
+
if tool is None:
|
| 248 |
+
# Hallucinated tool name. Record it as a failed step so the Auditor sees
|
| 249 |
+
# the problem and the Planner gets a chance to correct itself.
|
| 250 |
+
output: Any = f"Unknown tool '{step['tool_name']}'. Valid tools: {sorted(TOOL_MAP)}"
|
| 251 |
+
else:
|
| 252 |
+
try:
|
| 253 |
+
output = await tool.ainvoke({"query": step["tool_input"]})
|
| 254 |
+
except Exception as exc:
|
| 255 |
+
output = f"Tool raised {type(exc).__name__}: {exc}"
|
| 256 |
+
|
| 257 |
+
completed = {
|
| 258 |
+
"tool_name": step["tool_name"],
|
| 259 |
+
"tool_input": step["tool_input"],
|
| 260 |
+
"tool_output": output,
|
| 261 |
+
}
|
| 262 |
+
return {
|
| 263 |
+
"intermediate_steps": (state.get("intermediate_steps") or []) + [completed],
|
| 264 |
+
"plan": remaining,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
async def auditor_node(state: AgentState) -> Dict[str, Any]:
|
| 269 |
+
steps = state.get("intermediate_steps") or []
|
| 270 |
+
if not steps:
|
| 271 |
+
return {}
|
| 272 |
+
|
| 273 |
+
last = steps[-1]
|
| 274 |
+
try:
|
| 275 |
+
result = await structured_model(VerificationResult).ainvoke(
|
| 276 |
+
AUDITOR_PROMPT.format(
|
| 277 |
+
request=state["original_request"],
|
| 278 |
+
tool_name=last["tool_name"],
|
| 279 |
+
# Truncated: retrieval output can be tens of KB and the model has
|
| 280 |
+
# a 4096-token context window.
|
| 281 |
+
tool_output=json.dumps(last["tool_output"], default=str)[:2500],
|
| 282 |
+
)
|
| 283 |
+
)
|
| 284 |
+
audit = result.model_dump()
|
| 285 |
+
except Exception as exc:
|
| 286 |
+
# Treat an unreadable audit as a pass. Failing closed here would replan
|
| 287 |
+
# on infrastructure errors, which is the wrong response.
|
| 288 |
+
audit = {
|
| 289 |
+
"confidence_score": get_settings().audit_pass_score,
|
| 290 |
+
"is_relevant": True,
|
| 291 |
+
"reasoning": f"Audit unavailable ({type(exc).__name__}); proceeding.",
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
return {"verification_history": (state.get("verification_history") or []) + [audit]}
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
async def synthesizer_node(state: AgentState) -> Dict[str, Any]:
|
| 298 |
+
steps = state.get("intermediate_steps") or []
|
| 299 |
+
if not steps:
|
| 300 |
+
return {
|
| 301 |
+
"final_response": (
|
| 302 |
+
"I could not gather enough grounded information to answer that. "
|
| 303 |
+
"Try naming a specific metric and period."
|
| 304 |
+
)
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
context = "\n\n".join(
|
| 308 |
+
f"## Tool: {s['tool_name']}\nOutput: {json.dumps(s['tool_output'], default=str)[:3000]}"
|
| 309 |
+
for s in steps
|
| 310 |
+
)
|
| 311 |
+
response = await chat_model(temperature=0.2, max_tokens=1024).ainvoke(
|
| 312 |
+
SYNTHESIZER_PROMPT.format(request=state["original_request"], context=context)
|
| 313 |
+
)
|
| 314 |
+
return {"final_response": response.content}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
# --- Routing ----------------------------------------------------------------
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def route_after_audit(state: AgentState) -> str:
|
| 321 |
+
settings = get_settings()
|
| 322 |
+
history = state.get("verification_history") or []
|
| 323 |
+
|
| 324 |
+
plan = state.get("plan") or []
|
| 325 |
+
remaining = [s for s in plan if s["tool_name"] != "FINISH"]
|
| 326 |
+
audit_failed = bool(history) and history[-1]["confidence_score"] < settings.audit_pass_score
|
| 327 |
+
|
| 328 |
+
if audit_failed:
|
| 329 |
+
if state.get("replan_count", 0) < settings.max_replans:
|
| 330 |
+
return "planner"
|
| 331 |
+
# Replan budget spent. Keep going only if untried steps remain — they may
|
| 332 |
+
# yet succeed. Otherwise synthesise from imperfect evidence, which beats
|
| 333 |
+
# burning supersteps re-auditing failures until the limit trips.
|
| 334 |
+
return "execute_tool" if remaining else "synthesize"
|
| 335 |
+
|
| 336 |
+
return "execute_tool" if remaining else "synthesize"
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def build_graph():
|
| 340 |
+
builder = StateGraph(AgentState)
|
| 341 |
+
builder.add_node("gatekeeper", gatekeeper_node)
|
| 342 |
+
builder.add_node("planner", planner_node)
|
| 343 |
+
builder.add_node("execute_tool", tool_executor_node)
|
| 344 |
+
builder.add_node("verify", auditor_node)
|
| 345 |
+
builder.add_node("synthesize", synthesizer_node)
|
| 346 |
+
|
| 347 |
+
builder.set_entry_point("gatekeeper")
|
| 348 |
+
builder.add_conditional_edges(
|
| 349 |
+
"gatekeeper",
|
| 350 |
+
lambda s: END if (s.get("clarification_question") or s.get("final_response")) else "planner",
|
| 351 |
+
{END: END, "planner": "planner"},
|
| 352 |
+
)
|
| 353 |
+
builder.add_edge("planner", "execute_tool")
|
| 354 |
+
builder.add_edge("execute_tool", "verify")
|
| 355 |
+
builder.add_conditional_edges(
|
| 356 |
+
"verify",
|
| 357 |
+
route_after_audit,
|
| 358 |
+
{"planner": "planner", "execute_tool": "execute_tool", "synthesize": "synthesize"},
|
| 359 |
+
)
|
| 360 |
+
builder.add_edge("synthesize", END)
|
| 361 |
+
return builder.compile()
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
_graph = None
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def get_graph():
|
| 368 |
+
global _graph
|
| 369 |
+
if _graph is None:
|
| 370 |
+
_graph = build_graph()
|
| 371 |
+
return _graph
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def run_config() -> Dict[str, Any]:
|
| 375 |
+
return {"recursion_limit": get_settings().recursion_limit}
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
async def run_agent(query: str) -> AgentState:
|
| 379 |
+
"""Run to completion under a hard wall-clock ceiling.
|
| 380 |
+
|
| 381 |
+
The recursion limit bounds graph steps but not time — a single slow tool can
|
| 382 |
+
still hang the request, so both ceilings are needed.
|
| 383 |
+
"""
|
| 384 |
+
settings = get_settings()
|
| 385 |
+
initial: AgentState = {"original_request": query, "replan_count": 0}
|
| 386 |
+
return await asyncio.wait_for(
|
| 387 |
+
get_graph().ainvoke(initial, run_config()),
|
| 388 |
+
timeout=settings.agent_timeout_s,
|
| 389 |
+
)
|
backend/app/agent/llm.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat model construction, pointed at vLLM.
|
| 2 |
+
|
| 3 |
+
Structured output uses `method="json_schema"` rather than the library default.
|
| 4 |
+
Both work against vLLM 0.28, but json_schema routes through the OpenAI
|
| 5 |
+
`response_format` field, which vLLM honours with its constrained-decoding
|
| 6 |
+
backend (xgrammar). Measured 5/5 schema-valid Plan generations that way.
|
| 7 |
+
|
| 8 |
+
Do NOT reach for `extra_body={"guided_json": ...}`. In vLLM 0.28 that key is
|
| 9 |
+
silently ignored — the request succeeds and returns unconstrained prose, which
|
| 10 |
+
is far worse than an error because it looks like a model quality problem.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from typing import Type, TypeVar
|
| 16 |
+
|
| 17 |
+
from langchain_openai import ChatOpenAI
|
| 18 |
+
from pydantic import BaseModel
|
| 19 |
+
|
| 20 |
+
from ..config import get_settings
|
| 21 |
+
|
| 22 |
+
TModel = TypeVar("TModel", bound=BaseModel)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def chat_model(
|
| 26 |
+
temperature: float = 0.0,
|
| 27 |
+
max_tokens: int | None = None,
|
| 28 |
+
*,
|
| 29 |
+
streaming: bool = True,
|
| 30 |
+
) -> ChatOpenAI:
|
| 31 |
+
settings = get_settings()
|
| 32 |
+
return ChatOpenAI(
|
| 33 |
+
base_url=settings.vllm_base_url,
|
| 34 |
+
api_key=settings.vllm_api_key,
|
| 35 |
+
model=settings.vllm_model,
|
| 36 |
+
temperature=temperature,
|
| 37 |
+
max_completion_tokens=max_tokens,
|
| 38 |
+
timeout=settings.llm_timeout_s,
|
| 39 |
+
max_retries=settings.llm_max_retries,
|
| 40 |
+
# Default 120s kills the first chunk of a cold RunPod worker.
|
| 41 |
+
stream_chunk_timeout=settings.llm_timeout_s,
|
| 42 |
+
disable_streaming=not streaming,
|
| 43 |
+
# httpx2's gzip/brotli decoder crashes on RunPod responses
|
| 44 |
+
# (`process() takes no keyword arguments`) and langchain reports that
|
| 45 |
+
# as a generic "Connection error."
|
| 46 |
+
default_headers={"Accept-Encoding": "identity"},
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def structured_model(schema: Type[TModel], temperature: float = 0.0):
|
| 51 |
+
"""A model constrained to emit `schema`.
|
| 52 |
+
|
| 53 |
+
Qwen2.5-7B-AWQ is a much weaker instruction follower than the Gemini model
|
| 54 |
+
this replaced, so constrained decoding is load-bearing here rather than a
|
| 55 |
+
nicety: the Gatekeeper, Planner and Auditor all fail hard on invalid JSON.
|
| 56 |
+
"""
|
| 57 |
+
return chat_model(temperature=temperature, streaming=False).with_structured_output(
|
| 58 |
+
schema, method="json_schema"
|
| 59 |
+
)
|
backend/app/agent/resources.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lazily constructed shared resources.
|
| 2 |
+
|
| 3 |
+
The original `agent_tools.py` opened Qdrant, loaded a CrossEncoder and called
|
| 4 |
+
`setup_data_stores()` — which calls `exit()` on missing files — at module import
|
| 5 |
+
time. Importing it from a web server would kill the worker on a bad path and
|
| 6 |
+
hold the embedded Qdrant lock for the process lifetime. Everything here is
|
| 7 |
+
built on first use instead, and raises rather than exiting.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from functools import lru_cache
|
| 13 |
+
|
| 14 |
+
import qdrant_client
|
| 15 |
+
from qdrant_client.models import Distance, VectorParams
|
| 16 |
+
|
| 17 |
+
from ..config import get_settings
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@lru_cache(maxsize=1)
|
| 21 |
+
def get_qdrant() -> qdrant_client.QdrantClient:
|
| 22 |
+
settings = get_settings()
|
| 23 |
+
if settings.qdrant_url:
|
| 24 |
+
return qdrant_client.QdrantClient(
|
| 25 |
+
url=settings.qdrant_url,
|
| 26 |
+
api_key=settings.qdrant_api_key,
|
| 27 |
+
check_compatibility=False,
|
| 28 |
+
)
|
| 29 |
+
if not settings.qdrant_path.exists():
|
| 30 |
+
raise RuntimeError(
|
| 31 |
+
f"No Qdrant store at {settings.qdrant_path}. Run the ingestion scripts "
|
| 32 |
+
f"or set QDRANT_URL to a server."
|
| 33 |
+
)
|
| 34 |
+
return qdrant_client.QdrantClient(path=str(settings.qdrant_path))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def collection_dimension() -> int:
|
| 38 |
+
settings = get_settings()
|
| 39 |
+
info = get_qdrant().get_collection(settings.collection_name)
|
| 40 |
+
params = info.config.params
|
| 41 |
+
vectors = params.vectors
|
| 42 |
+
if isinstance(vectors, VectorParams):
|
| 43 |
+
return int(vectors.size)
|
| 44 |
+
if isinstance(vectors, dict): # named vectors
|
| 45 |
+
return int(next(iter(vectors.values())).size)
|
| 46 |
+
raise RuntimeError(f"Cannot determine vector size for '{settings.collection_name}'")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def collection_distance() -> Distance | None:
|
| 50 |
+
settings = get_settings()
|
| 51 |
+
vectors = get_qdrant().get_collection(settings.collection_name).config.params.vectors
|
| 52 |
+
if isinstance(vectors, VectorParams):
|
| 53 |
+
return vectors.distance
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@lru_cache(maxsize=1)
|
| 58 |
+
def get_reranker():
|
| 59 |
+
from sentence_transformers import CrossEncoder
|
| 60 |
+
|
| 61 |
+
return CrossEncoder(get_settings().reranker_model)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@lru_cache(maxsize=1)
|
| 65 |
+
def get_sql_database():
|
| 66 |
+
import sqlite3
|
| 67 |
+
|
| 68 |
+
import pandas as pd
|
| 69 |
+
from langchain_community.utilities import SQLDatabase
|
| 70 |
+
|
| 71 |
+
from ..config import PROJECT_ROOT
|
| 72 |
+
|
| 73 |
+
settings = get_settings()
|
| 74 |
+
if not settings.sqlite_path.exists():
|
| 75 |
+
csv_path = PROJECT_ROOT / "data" / "alphabet_financials_structured.csv"
|
| 76 |
+
if not csv_path.exists():
|
| 77 |
+
raise RuntimeError(
|
| 78 |
+
f"No SQLite database at {settings.sqlite_path} and no CSV at {csv_path}"
|
| 79 |
+
)
|
| 80 |
+
settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
frame = pd.read_csv(csv_path)
|
| 82 |
+
with sqlite3.connect(settings.sqlite_path) as conn:
|
| 83 |
+
frame.to_sql(settings.table_name, conn, if_exists="replace", index=False)
|
| 84 |
+
|
| 85 |
+
return SQLDatabase.from_uri(
|
| 86 |
+
f"sqlite:///{settings.sqlite_path}",
|
| 87 |
+
include_tables=[settings.table_name],
|
| 88 |
+
)
|
backend/app/agent/tools.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The four specialist tools, ported to the local model.
|
| 2 |
+
|
| 3 |
+
Behaviour matches `agent_tools.py`; only the model behind each call changed.
|
| 4 |
+
Print statements are gone — node progress now travels over the event stream.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import sqlite3
|
| 11 |
+
from typing import Any, Dict, List
|
| 12 |
+
|
| 13 |
+
import pandas as pd
|
| 14 |
+
from langchain_core.tools import tool
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
|
| 17 |
+
from ..config import get_settings
|
| 18 |
+
from .embeddings import assert_dimension_matches, get_embeddings
|
| 19 |
+
from .llm import chat_model, structured_model
|
| 20 |
+
from .resources import collection_dimension, get_qdrant, get_reranker, get_sql_database
|
| 21 |
+
|
| 22 |
+
_QUERY_REWRITE = (
|
| 23 |
+
'Rewrite the following query for a semantic search system. Respond with ONLY '
|
| 24 |
+
'the rewritten query, no preamble.\nQuery: "{query}"\nRewritten:'
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def optimize_query(query: str) -> str:
|
| 29 |
+
"""Rewrite a user query for retrieval.
|
| 30 |
+
|
| 31 |
+
Falls back to the original query on failure: a bad rewrite should degrade
|
| 32 |
+
retrieval, not fail the request.
|
| 33 |
+
"""
|
| 34 |
+
try:
|
| 35 |
+
response = await chat_model(temperature=0.0, max_tokens=128).ainvoke(
|
| 36 |
+
_QUERY_REWRITE.format(query=query)
|
| 37 |
+
)
|
| 38 |
+
rewritten = (response.content or "").strip().strip('"')
|
| 39 |
+
return rewritten or query
|
| 40 |
+
except Exception:
|
| 41 |
+
return query
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@tool
|
| 45 |
+
async def librarian_rag_tool(query: str) -> List[Dict[str, Any]]:
|
| 46 |
+
"""Retrieves deep, contextual information from Alphabet's financial filings."""
|
| 47 |
+
settings = get_settings()
|
| 48 |
+
assert_dimension_matches(collection_dimension())
|
| 49 |
+
|
| 50 |
+
optimized = await optimize_query(query)
|
| 51 |
+
embedding = await asyncio.to_thread(get_embeddings().embed_query, optimized)
|
| 52 |
+
|
| 53 |
+
hits = await asyncio.to_thread(
|
| 54 |
+
lambda: get_qdrant().query_points(
|
| 55 |
+
collection_name=settings.collection_name,
|
| 56 |
+
query=embedding,
|
| 57 |
+
limit=settings.retrieval_candidates,
|
| 58 |
+
with_payload=True,
|
| 59 |
+
).points
|
| 60 |
+
)
|
| 61 |
+
if not hits:
|
| 62 |
+
return []
|
| 63 |
+
|
| 64 |
+
# CrossEncoder is CPU-bound; keep it off the event loop.
|
| 65 |
+
pairs = [[optimized, hit.payload.get("content", "")] for hit in hits]
|
| 66 |
+
scores = await asyncio.to_thread(get_reranker().predict, pairs)
|
| 67 |
+
|
| 68 |
+
ranked = sorted(zip(hits, scores), key=lambda pair: pair[1], reverse=True)
|
| 69 |
+
return [
|
| 70 |
+
{
|
| 71 |
+
"source": hit.payload.get("source"),
|
| 72 |
+
"content": hit.payload.get("content"),
|
| 73 |
+
"summary": hit.payload.get("summary"),
|
| 74 |
+
"rerank_score": float(score),
|
| 75 |
+
}
|
| 76 |
+
for hit, score in ranked[: settings.retrieval_top_k]
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class _SqlQuery(BaseModel):
|
| 81 |
+
sql: str = Field(description="A single read-only SQLite SELECT statement.")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
_SQL_PROMPT = """Write one SQLite SELECT query answering the question.
|
| 85 |
+
|
| 86 |
+
Schema:
|
| 87 |
+
{schema}
|
| 88 |
+
|
| 89 |
+
Rules:
|
| 90 |
+
- SELECT only. No INSERT, UPDATE, DELETE, DROP or PRAGMA.
|
| 91 |
+
- Use only the columns shown above.
|
| 92 |
+
- SQLite syntax: no YEAR(), no TOP. Use LIMIT.
|
| 93 |
+
- Return the query only.
|
| 94 |
+
|
| 95 |
+
Question: {question}
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
# Only a bare SELECT/WITH is allowed through. The question text reaches the model
|
| 99 |
+
# that writes this SQL, so the generated statement is untrusted input: a prompt
|
| 100 |
+
# injection that talks the model into a DROP must not reach the database.
|
| 101 |
+
_FORBIDDEN_SQL = (
|
| 102 |
+
"insert", "update", "delete", "drop", "alter", "create",
|
| 103 |
+
"attach", "detach", "pragma", "replace", "vacuum",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _run_select(sql: str) -> str:
|
| 108 |
+
settings = get_settings()
|
| 109 |
+
stripped = sql.strip().rstrip(";").strip()
|
| 110 |
+
lowered = stripped.lower()
|
| 111 |
+
|
| 112 |
+
if not (lowered.startswith("select") or lowered.startswith("with")):
|
| 113 |
+
return f"Refused: only SELECT queries are allowed, got: {stripped[:120]}"
|
| 114 |
+
if any(word in lowered.split() for word in _FORBIDDEN_SQL):
|
| 115 |
+
return f"Refused: statement contains a write operation: {stripped[:120]}"
|
| 116 |
+
if ";" in stripped:
|
| 117 |
+
return "Refused: multiple statements are not allowed."
|
| 118 |
+
|
| 119 |
+
with sqlite3.connect(f"file:{settings.sqlite_path}?mode=ro", uri=True) as conn:
|
| 120 |
+
df = pd.read_sql_query(stripped, conn)
|
| 121 |
+
|
| 122 |
+
if df.empty:
|
| 123 |
+
return f"Query returned no rows.\nSQL: {stripped}"
|
| 124 |
+
return f"SQL: {stripped}\n\n{df.to_string(index=False, max_rows=40)}"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@tool
|
| 128 |
+
async def analyst_sql_tool(query: str) -> str:
|
| 129 |
+
"""Answers specific questions about revenue, net income, etc., from a SQL database."""
|
| 130 |
+
# Replaces langchain's `create_sql_agent`, which was an agent loop costing
|
| 131 |
+
# several LLM round trips and was the slowest node in the graph. One
|
| 132 |
+
# constrained-decoding call plus a direct execution is faster, and it drops a
|
| 133 |
+
# dependency on the legacy langchain-community agent stack.
|
| 134 |
+
try:
|
| 135 |
+
schema = await asyncio.to_thread(get_sql_database().get_table_info)
|
| 136 |
+
except Exception as exc:
|
| 137 |
+
return f"Could not read database schema: {type(exc).__name__}: {exc}"
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
generated = await structured_model(_SqlQuery).ainvoke(
|
| 141 |
+
_SQL_PROMPT.format(schema=schema, question=query)
|
| 142 |
+
)
|
| 143 |
+
except Exception as exc:
|
| 144 |
+
return f"Could not generate SQL: {type(exc).__name__}: {exc}"
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
return await asyncio.to_thread(_run_select, generated.sql)
|
| 148 |
+
except Exception as exc:
|
| 149 |
+
return f"SQL failed: {type(exc).__name__}: {exc}\nSQL: {generated.sql}"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _trend_sync(_: str) -> str:
|
| 153 |
+
settings = get_settings()
|
| 154 |
+
with sqlite3.connect(settings.sqlite_path) as conn:
|
| 155 |
+
df = pd.read_sql_query(
|
| 156 |
+
f"SELECT * FROM {settings.table_name} ORDER BY year, quarter", conn
|
| 157 |
+
)
|
| 158 |
+
if df.empty:
|
| 159 |
+
return "No rows in the financial summary table."
|
| 160 |
+
|
| 161 |
+
df["period"] = df["year"].astype(str) + "-" + df["quarter"].astype(str)
|
| 162 |
+
df = df.set_index("period")
|
| 163 |
+
metric = "revenue_usd_billions"
|
| 164 |
+
|
| 165 |
+
qoq = df[metric].pct_change()
|
| 166 |
+
yoy = df[metric].pct_change(4)
|
| 167 |
+
return (
|
| 168 |
+
f"Analysis of {metric} from {df.index[0]} to {df.index[-1]}: "
|
| 169 |
+
f"${df[metric].iloc[0]:.1f}B -> ${df[metric].iloc[-1]:.1f}B. "
|
| 170 |
+
f"Latest QoQ {qoq.iloc[-1]:.1%}, latest YoY {yoy.iloc[-1]:.1%}."
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@tool
|
| 175 |
+
async def analyst_trend_tool(query: str) -> str:
|
| 176 |
+
"""Analyzes financial trends like QoQ or YoY growth from the SQL database."""
|
| 177 |
+
return await asyncio.to_thread(_trend_sync, query)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@tool
|
| 181 |
+
async def scout_web_search_tool(query: str) -> str:
|
| 182 |
+
"""Searches the web for real-time information like stock prices or recent news."""
|
| 183 |
+
from langchain_community.tools import DuckDuckGoSearchRun
|
| 184 |
+
|
| 185 |
+
try:
|
| 186 |
+
return await DuckDuckGoSearchRun().arun(query)
|
| 187 |
+
except Exception as exc:
|
| 188 |
+
return f"Web search failed: {type(exc).__name__}: {exc}"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
ALL_TOOLS = [
|
| 192 |
+
librarian_rag_tool,
|
| 193 |
+
analyst_sql_tool,
|
| 194 |
+
analyst_trend_tool,
|
| 195 |
+
scout_web_search_tool,
|
| 196 |
+
]
|
| 197 |
+
TOOL_MAP = {t.name: t for t in ALL_TOOLS}
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Environment-driven configuration.
|
| 2 |
+
|
| 3 |
+
Replaces the hardcoded API key in `agent_tools.py`. Nothing in the new code
|
| 4 |
+
path reads a literal credential.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Literal, Optional
|
| 12 |
+
|
| 13 |
+
from pydantic import Field, field_validator
|
| 14 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 15 |
+
|
| 16 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Settings(BaseSettings):
|
| 20 |
+
model_config = SettingsConfigDict(
|
| 21 |
+
env_file=PROJECT_ROOT / ".env",
|
| 22 |
+
env_file_encoding="utf-8",
|
| 23 |
+
extra="ignore",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# --- Inference -----------------------------------------------------------
|
| 27 |
+
# Points at local vLLM in development and at the RunPod endpoint in
|
| 28 |
+
# deployment. Nothing else changes between the two.
|
| 29 |
+
vllm_base_url: str = "http://localhost:8000/v1"
|
| 30 |
+
vllm_api_key: str = "EMPTY" # vLLM ignores it, but the client requires one.
|
| 31 |
+
vllm_model: str = "Qwen/Qwen2.5-7B-Instruct-AWQ"
|
| 32 |
+
# First token after a RunPod cold start is often 2–3 minutes. The LangChain
|
| 33 |
+
# stream-chunk default of 120s aborts that wait and the 300s agent ceiling
|
| 34 |
+
# then fires while the Planner is still on its first call.
|
| 35 |
+
llm_timeout_s: float = 240.0
|
| 36 |
+
# The openai client retries with backoff by default, which turns one dead
|
| 37 |
+
# request into minutes of silence. Keep it low and let the agent's own
|
| 38 |
+
# wall-clock ceiling be the real backstop.
|
| 39 |
+
llm_max_retries: int = 1
|
| 40 |
+
|
| 41 |
+
# --- Embeddings ----------------------------------------------------------
|
| 42 |
+
# 'google' matches the vectors already in Qdrant (text-embedding-004, 768d).
|
| 43 |
+
# 'local' is where this is going, but flipping it requires a full re-index —
|
| 44 |
+
# vectors from different models are not comparable, so a mismatched
|
| 45 |
+
# collection returns plausible-looking nonsense rather than an error.
|
| 46 |
+
embedding_provider: Literal["google", "local"] = "local"
|
| 47 |
+
google_api_key: Optional[str] = None
|
| 48 |
+
local_embedding_model: str = "BAAI/bge-small-en-v1.5"
|
| 49 |
+
|
| 50 |
+
# --- Stores --------------------------------------------------------------
|
| 51 |
+
# Embedded (path) mode is single-process and lock-protected: fine for a CLI,
|
| 52 |
+
# unusable behind a multi-worker server or on ephemeral container storage.
|
| 53 |
+
# Set qdrant_url to move to server mode.
|
| 54 |
+
qdrant_url: Optional[str] = None
|
| 55 |
+
qdrant_api_key: Optional[str] = None
|
| 56 |
+
qdrant_path: Path = PROJECT_ROOT / "data" / "qdrant_db"
|
| 57 |
+
collection_name: str = "financial_docs_alphabet"
|
| 58 |
+
|
| 59 |
+
sqlite_path: Path = PROJECT_ROOT / "data" / "financials.db"
|
| 60 |
+
table_name: str = "financials_summary"
|
| 61 |
+
|
| 62 |
+
@field_validator("qdrant_url", "qdrant_api_key", mode="before")
|
| 63 |
+
@classmethod
|
| 64 |
+
def _strip_secrets(cls, value: object) -> object:
|
| 65 |
+
if isinstance(value, str):
|
| 66 |
+
stripped = value.strip()
|
| 67 |
+
return stripped or None
|
| 68 |
+
return value
|
| 69 |
+
|
| 70 |
+
reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 71 |
+
retrieval_candidates: int = 20
|
| 72 |
+
retrieval_top_k: int = 5
|
| 73 |
+
|
| 74 |
+
# Tenant tag written into every Qdrant payload. Shared reference filings are
|
| 75 |
+
# owned by this pseudo-tenant; per-user uploads will carry their own user_id.
|
| 76 |
+
# Present from the first index so enabling isolation never needs a re-index.
|
| 77 |
+
public_tenant: str = "public"
|
| 78 |
+
|
| 79 |
+
# --- Agent ceilings ------------------------------------------------------
|
| 80 |
+
# Auditor-driven replans, hard capped. Without this the router replans
|
| 81 |
+
# forever whenever the model cannot satisfy the Auditor.
|
| 82 |
+
max_replans: int = 2
|
| 83 |
+
audit_pass_score: int = 3
|
| 84 |
+
# Plans longer than this are truncated, which is what makes the superstep
|
| 85 |
+
# budget below a bounded quantity rather than a guess.
|
| 86 |
+
max_plan_steps: int = 3
|
| 87 |
+
|
| 88 |
+
# recursion_limit is a superstep budget, not a loop guard — max_replans is
|
| 89 |
+
# the loop guard. Worst case is:
|
| 90 |
+
# gatekeeper + (max_replans + 1) * (planner + max_plan_steps * 2) + synthesize
|
| 91 |
+
# which is 1 + 3 * (1 + 10) + 1 = 35 at the defaults. 50 leaves headroom.
|
| 92 |
+
# Setting it near the worst case is what produced GraphRecursionError before.
|
| 93 |
+
recursion_limit: int = 50
|
| 94 |
+
agent_timeout_s: float = 600.0
|
| 95 |
+
|
| 96 |
+
# 'mock' replays canned traces so the UI can be developed without a GPU.
|
| 97 |
+
# 'live' runs the LangGraph supervisor against vLLM + Qdrant.
|
| 98 |
+
agent_mode: Literal["mock", "live"] = "mock"
|
| 99 |
+
# Load BGE + CrossEncoder during lifespan. Turn off on 512 MB hosts
|
| 100 |
+
# (Render Free) so uvicorn can bind before the first Librarian call.
|
| 101 |
+
warmup_on_start: bool = True
|
| 102 |
+
|
| 103 |
+
def require_google_key(self) -> str:
|
| 104 |
+
if not self.google_api_key:
|
| 105 |
+
raise RuntimeError(
|
| 106 |
+
"embedding_provider='google' needs GOOGLE_API_KEY set. Either export it "
|
| 107 |
+
"or switch EMBEDDING_PROVIDER=local (which requires re-indexing Qdrant)."
|
| 108 |
+
)
|
| 109 |
+
return self.google_api_key
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@lru_cache(maxsize=1)
|
| 113 |
+
def get_settings() -> Settings:
|
| 114 |
+
return Settings()
|
backend/app/events.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wire format for the agent event stream.
|
| 2 |
+
|
| 3 |
+
This module is the contract between the LangGraph supervisor and any client.
|
| 4 |
+
The frontend mirrors these shapes in `frontend/src/lib/events.ts` — change one,
|
| 5 |
+
change the other.
|
| 6 |
+
|
| 7 |
+
Two rules keep the stream usable:
|
| 8 |
+
|
| 9 |
+
1. Every event carries `seq`. SSE gives no ordering guarantee across reconnects,
|
| 10 |
+
and a client that buffers out-of-order node transitions will render the graph
|
| 11 |
+
walk wrong. `seq` is monotonic within a run.
|
| 12 |
+
2. `type` is duplicated in the JSON payload as well as the SSE `event:` field.
|
| 13 |
+
The redundancy costs a few bytes and makes a raw `curl` transcript
|
| 14 |
+
self-describing.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import time
|
| 21 |
+
from enum import Enum
|
| 22 |
+
from typing import Any, Literal, Optional, Union
|
| 23 |
+
|
| 24 |
+
from pydantic import BaseModel, Field
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class NodeName(str, Enum):
|
| 28 |
+
"""Nodes in the supervisor graph, matching `agent_tools.py`."""
|
| 29 |
+
|
| 30 |
+
gatekeeper = "gatekeeper"
|
| 31 |
+
planner = "planner"
|
| 32 |
+
tool_executor = "tool_executor"
|
| 33 |
+
auditor = "auditor"
|
| 34 |
+
synthesizer = "synthesizer"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ToolName(str, Enum):
|
| 38 |
+
librarian = "librarian_rag_tool"
|
| 39 |
+
sql_analyst = "analyst_sql_tool"
|
| 40 |
+
trend_analyst = "analyst_trend_tool"
|
| 41 |
+
scout = "scout_web_search_tool"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class EventType(str, Enum):
|
| 45 |
+
run_start = "run_start"
|
| 46 |
+
node_start = "node_start"
|
| 47 |
+
node_end = "node_end"
|
| 48 |
+
plan = "plan"
|
| 49 |
+
tool_start = "tool_start"
|
| 50 |
+
tool_end = "tool_end"
|
| 51 |
+
audit = "audit"
|
| 52 |
+
clarification = "clarification"
|
| 53 |
+
token = "token"
|
| 54 |
+
run_end = "run_end"
|
| 55 |
+
error = "error"
|
| 56 |
+
heartbeat = "heartbeat"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class _Base(BaseModel):
|
| 60 |
+
seq: int
|
| 61 |
+
ts: float = Field(default_factory=time.time)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class RunStart(_Base):
|
| 65 |
+
type: Literal[EventType.run_start] = EventType.run_start
|
| 66 |
+
run_id: str
|
| 67 |
+
conversation_id: str
|
| 68 |
+
query: str
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class NodeStart(_Base):
|
| 72 |
+
type: Literal[EventType.node_start] = EventType.node_start
|
| 73 |
+
node: NodeName
|
| 74 |
+
# Incremented each time a node is re-entered, so the UI can show
|
| 75 |
+
# "Planner (attempt 2)" after the Auditor forces a replan.
|
| 76 |
+
attempt: int = 1
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class NodeEnd(_Base):
|
| 80 |
+
type: Literal[EventType.node_end] = EventType.node_end
|
| 81 |
+
node: NodeName
|
| 82 |
+
attempt: int = 1
|
| 83 |
+
duration_ms: int
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class PlanStep(BaseModel):
|
| 87 |
+
tool_name: str
|
| 88 |
+
tool_input: str
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class Plan(_Base):
|
| 92 |
+
"""Emitted by the planner so the UI can show intent before any tool runs."""
|
| 93 |
+
|
| 94 |
+
type: Literal[EventType.plan] = EventType.plan
|
| 95 |
+
attempt: int = 1
|
| 96 |
+
steps: list[PlanStep]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class ToolStart(_Base):
|
| 100 |
+
type: Literal[EventType.tool_start] = EventType.tool_start
|
| 101 |
+
tool_name: str
|
| 102 |
+
tool_input: str
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class ToolEnd(_Base):
|
| 106 |
+
type: Literal[EventType.tool_end] = EventType.tool_end
|
| 107 |
+
tool_name: str
|
| 108 |
+
duration_ms: int
|
| 109 |
+
ok: bool = True
|
| 110 |
+
# Never the full tool output. Retrieval hits can be tens of kilobytes and
|
| 111 |
+
# the browser does not need them to render a trace.
|
| 112 |
+
preview: Optional[str] = None
|
| 113 |
+
result_count: Optional[int] = None
|
| 114 |
+
error: Optional[str] = None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class Audit(_Base):
|
| 118 |
+
type: Literal[EventType.audit] = EventType.audit
|
| 119 |
+
attempt: int = 1
|
| 120 |
+
confidence_score: int = Field(ge=1, le=5)
|
| 121 |
+
is_relevant: bool
|
| 122 |
+
reasoning: str
|
| 123 |
+
# Mirrors the `< 3` threshold in the router.
|
| 124 |
+
action: Literal["accept", "replan"]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class Clarification(_Base):
|
| 128 |
+
"""Terminal event when the Gatekeeper judges the request too vague."""
|
| 129 |
+
|
| 130 |
+
type: Literal[EventType.clarification] = EventType.clarification
|
| 131 |
+
question: str
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class Token(_Base):
|
| 135 |
+
type: Literal[EventType.token] = EventType.token
|
| 136 |
+
text: str
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class RunEnd(_Base):
|
| 140 |
+
type: Literal[EventType.run_end] = EventType.run_end
|
| 141 |
+
run_id: str
|
| 142 |
+
duration_ms: int
|
| 143 |
+
# Per-node wall time, keyed by node name. The README calls for per-node
|
| 144 |
+
# latency instrumentation from the start; this is where it surfaces.
|
| 145 |
+
node_timings_ms: dict[str, int] = Field(default_factory=dict)
|
| 146 |
+
llm_calls: int = 0
|
| 147 |
+
output_tokens: int = 0
|
| 148 |
+
replans: int = 0
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class Error(_Base):
|
| 152 |
+
type: Literal[EventType.error] = EventType.error
|
| 153 |
+
code: str
|
| 154 |
+
message: str
|
| 155 |
+
retryable: bool = False
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class Heartbeat(_Base):
|
| 159 |
+
"""Named SSE event so browsers treat the stream as active during long LLM waits.
|
| 160 |
+
|
| 161 |
+
SSE comments (`: ping`) are dropped by some clients and do not reset
|
| 162 |
+
fetch timeouts; Chrome then reports `Connection error` around five minutes.
|
| 163 |
+
"""
|
| 164 |
+
|
| 165 |
+
type: Literal[EventType.heartbeat] = EventType.heartbeat
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
AgentEvent = Union[
|
| 169 |
+
RunStart,
|
| 170 |
+
NodeStart,
|
| 171 |
+
NodeEnd,
|
| 172 |
+
Plan,
|
| 173 |
+
ToolStart,
|
| 174 |
+
ToolEnd,
|
| 175 |
+
Audit,
|
| 176 |
+
Clarification,
|
| 177 |
+
Token,
|
| 178 |
+
RunEnd,
|
| 179 |
+
Error,
|
| 180 |
+
Heartbeat,
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def sse(event: AgentEvent) -> str:
|
| 185 |
+
"""Serialise one event as an SSE frame.
|
| 186 |
+
|
| 187 |
+
`json.dumps` on `model_dump(mode="json")` rather than `model_dump_json()`
|
| 188 |
+
so enum members land as their string values.
|
| 189 |
+
"""
|
| 190 |
+
payload: dict[str, Any] = event.model_dump(mode="json")
|
| 191 |
+
return f"event: {payload['type']}\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n"
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class ChatRequest(BaseModel):
|
| 195 |
+
query: str = Field(min_length=1, max_length=2000)
|
| 196 |
+
conversation_id: Optional[str] = None
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI surface for the agent.
|
| 2 |
+
|
| 3 |
+
`AGENT_MODE=mock` (default) replays canned traces so the UI can be developed
|
| 4 |
+
without a GPU. `AGENT_MODE=live` runs the LangGraph supervisor. The event
|
| 5 |
+
contract is the same either way; only the generator behind StreamingResponse
|
| 6 |
+
changes.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
from contextlib import asynccontextmanager
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import AsyncIterator
|
| 15 |
+
|
| 16 |
+
from fastapi import FastAPI, Query
|
| 17 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 18 |
+
from fastapi.responses import StreamingResponse
|
| 19 |
+
from fastapi.staticfiles import StaticFiles
|
| 20 |
+
|
| 21 |
+
from .config import get_settings
|
| 22 |
+
from .events import ChatRequest
|
| 23 |
+
from .mock_agent import replay
|
| 24 |
+
from .stream import live_stream
|
| 25 |
+
|
| 26 |
+
SSE_HEADERS = {
|
| 27 |
+
"Cache-Control": "no-cache, no-transform",
|
| 28 |
+
"Connection": "keep-alive",
|
| 29 |
+
"X-Accel-Buffering": "no",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _warmup() -> None:
|
| 34 |
+
"""Load embedding + reranker weights and open stores before the first query.
|
| 35 |
+
|
| 36 |
+
The first Librarian call otherwise spends ~15s downloading/loading models,
|
| 37 |
+
which on Fargate is paid by whoever happens to ask first.
|
| 38 |
+
"""
|
| 39 |
+
from .agent.embeddings import get_embeddings
|
| 40 |
+
from .agent.resources import get_qdrant, get_reranker, get_sql_database
|
| 41 |
+
|
| 42 |
+
get_embeddings()
|
| 43 |
+
get_reranker()
|
| 44 |
+
get_qdrant()
|
| 45 |
+
get_sql_database()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@asynccontextmanager
|
| 49 |
+
async def lifespan(_app: FastAPI):
|
| 50 |
+
settings = get_settings()
|
| 51 |
+
if settings.agent_mode == "live" and settings.warmup_on_start:
|
| 52 |
+
await asyncio.to_thread(_warmup)
|
| 53 |
+
yield
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
app = FastAPI(title="FinRAG API", version="0.2.0", lifespan=lifespan)
|
| 57 |
+
|
| 58 |
+
app.add_middleware(
|
| 59 |
+
CORSMiddleware,
|
| 60 |
+
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
| 61 |
+
allow_credentials=True,
|
| 62 |
+
allow_methods=["*"],
|
| 63 |
+
allow_headers=["*"],
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@app.get("/api/health")
|
| 68 |
+
async def health() -> dict[str, object]:
|
| 69 |
+
settings = get_settings()
|
| 70 |
+
return {
|
| 71 |
+
"status": "ok",
|
| 72 |
+
"agent": settings.agent_mode,
|
| 73 |
+
"vllm": settings.vllm_base_url,
|
| 74 |
+
"embeddings": settings.embedding_provider,
|
| 75 |
+
"qdrant": str(settings.qdrant_url or settings.qdrant_path),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@app.post("/api/chat/stream")
|
| 80 |
+
async def chat_stream(
|
| 81 |
+
body: ChatRequest,
|
| 82 |
+
speed: float = Query(1.0, gt=0, le=50, description="Mock replay speed. Ignored in live mode."),
|
| 83 |
+
) -> StreamingResponse:
|
| 84 |
+
"""Stream agent state and answer tokens as Server-Sent Events.
|
| 85 |
+
|
| 86 |
+
POST rather than GET because the query goes in the body and, once auth
|
| 87 |
+
lands, the JWT goes in an Authorization header. That rules out `EventSource`
|
| 88 |
+
on the client — it is GET-only and cannot set headers.
|
| 89 |
+
"""
|
| 90 |
+
settings = get_settings()
|
| 91 |
+
|
| 92 |
+
async def stream() -> AsyncIterator[bytes]:
|
| 93 |
+
frames = (
|
| 94 |
+
live_stream(body.query, body.conversation_id)
|
| 95 |
+
if settings.agent_mode == "live"
|
| 96 |
+
else replay(body.query, body.conversation_id, speed)
|
| 97 |
+
)
|
| 98 |
+
async for frame in frames:
|
| 99 |
+
yield frame.encode("utf-8")
|
| 100 |
+
|
| 101 |
+
return StreamingResponse(stream(), media_type="text/event-stream", headers=SSE_HEADERS)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
_dist = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
| 105 |
+
if _dist.is_dir():
|
| 106 |
+
app.mount("/", StaticFiles(directory=str(_dist), html=True), name="spa")
|
backend/app/mock_agent.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Canned replays of a supervisor run, for building the frontend without a GPU.
|
| 2 |
+
|
| 3 |
+
Timings default to the real thing rather than something snappy. A live run is
|
| 4 |
+
15-30s because one query fans out into Gatekeeper -> Planner -> tools ->
|
| 5 |
+
Auditor -> Synthesizer, and a UI that feels fine at 2s can be unusable at 25s.
|
| 6 |
+
Pass `speed` to compress the replay once the layout is settled.
|
| 7 |
+
|
| 8 |
+
Replaced wholesale by `astream_events` over the real graph later; the event
|
| 9 |
+
shapes it emits are the ones in `events.py`.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import re
|
| 16 |
+
import uuid
|
| 17 |
+
from collections import defaultdict
|
| 18 |
+
from typing import AsyncIterator
|
| 19 |
+
|
| 20 |
+
from .events import (
|
| 21 |
+
Audit,
|
| 22 |
+
Clarification,
|
| 23 |
+
Error,
|
| 24 |
+
NodeEnd,
|
| 25 |
+
NodeName,
|
| 26 |
+
NodeStart,
|
| 27 |
+
Plan,
|
| 28 |
+
PlanStep,
|
| 29 |
+
RunEnd,
|
| 30 |
+
RunStart,
|
| 31 |
+
Token,
|
| 32 |
+
ToolEnd,
|
| 33 |
+
ToolStart,
|
| 34 |
+
sse,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Wall times observed per node, in seconds. The SQL analyst is slow because
|
| 38 |
+
# `create_sql_agent` is itself an agent loop: schema introspection, query
|
| 39 |
+
# drafting, then execution, so several LLM round trips.
|
| 40 |
+
GATEKEEPER_S = 1.2
|
| 41 |
+
PLANNER_S = 2.6
|
| 42 |
+
AUDITOR_S = 2.1
|
| 43 |
+
TOOL_S = {
|
| 44 |
+
"librarian_rag_tool": 2.4, # query rewrite + embed + Qdrant + CrossEncoder
|
| 45 |
+
"analyst_sql_tool": 4.3,
|
| 46 |
+
"analyst_trend_tool": 0.4, # pure pandas, no LLM
|
| 47 |
+
"scout_web_search_tool": 2.7,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
# 40 tok/s single-stream, per the benchmark table in the README.
|
| 51 |
+
SECONDS_PER_TOKEN = 1 / 40
|
| 52 |
+
|
| 53 |
+
ACTIONABLE = re.compile(
|
| 54 |
+
r"\b(revenue|income|profit|margin|trend|growth|risk|competiti\w*|segment|"
|
| 55 |
+
r"cloud|capex|10-?k|filing|quarter|q[1-4]|fy|yoy|qoq|year|compare|"
|
| 56 |
+
r"guidance|operating|expense|headcount|buyback|dividend)\b",
|
| 57 |
+
re.I,
|
| 58 |
+
)
|
| 59 |
+
QUANTITATIVE = re.compile(r"\b(revenue|income|margin|trend|growth|yoy|qoq|quarter|q[1-4]|capex|expense)\b", re.I)
|
| 60 |
+
QUALITATIVE = re.compile(r"\b(risk|competiti\w*|10-?k|filing|strategy|regulat\w*|moat|threat)\b", re.I)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class _Run:
|
| 64 |
+
"""Sequence numbering, timing accumulation and speed scaling for one run."""
|
| 65 |
+
|
| 66 |
+
def __init__(self, query: str, conversation_id: str, speed: float) -> None:
|
| 67 |
+
self.query = query
|
| 68 |
+
self.conversation_id = conversation_id
|
| 69 |
+
self.run_id = f"run_{uuid.uuid4().hex[:12]}"
|
| 70 |
+
self.speed = max(speed, 0.05)
|
| 71 |
+
self._seq = 0
|
| 72 |
+
# Reported latencies are nominal, not measured: they describe what the
|
| 73 |
+
# run would cost at speed=1. Measured time would collapse under a speed
|
| 74 |
+
# multiplier and give the UI unrealistic numbers to lay out against.
|
| 75 |
+
self.nominal_ms = 0
|
| 76 |
+
self.node_timings_ms: dict[str, int] = defaultdict(int)
|
| 77 |
+
self.llm_calls = 0
|
| 78 |
+
self.output_tokens = 0
|
| 79 |
+
self.replans = 0
|
| 80 |
+
|
| 81 |
+
def seq(self) -> int:
|
| 82 |
+
self._seq += 1
|
| 83 |
+
return self._seq
|
| 84 |
+
|
| 85 |
+
async def sleep(self, seconds: float) -> None:
|
| 86 |
+
self.nominal_ms += int(seconds * 1000)
|
| 87 |
+
await asyncio.sleep(seconds / self.speed)
|
| 88 |
+
|
| 89 |
+
def node_start(self, node: NodeName, attempt: int = 1) -> str:
|
| 90 |
+
return sse(NodeStart(seq=self.seq(), node=node, attempt=attempt))
|
| 91 |
+
|
| 92 |
+
def node_end(self, node: NodeName, seconds: float, attempt: int = 1) -> str:
|
| 93 |
+
ms = int(seconds * 1000)
|
| 94 |
+
self.node_timings_ms[node.value] += ms
|
| 95 |
+
return sse(NodeEnd(seq=self.seq(), node=node, attempt=attempt, duration_ms=ms))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _pick_scenario(query: str) -> str:
|
| 99 |
+
if not ACTIONABLE.search(query) or len(query.split()) < 4:
|
| 100 |
+
return "clarification"
|
| 101 |
+
if QUANTITATIVE.search(query) and QUALITATIVE.search(query):
|
| 102 |
+
return "replan"
|
| 103 |
+
return "simple"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
TOOL_RESULTS = {
|
| 107 |
+
"analyst_sql_tool": (
|
| 108 |
+
"FY2024 total revenue: $350.0B (FY2023: $307.4B).",
|
| 109 |
+
1,
|
| 110 |
+
),
|
| 111 |
+
"analyst_trend_tool": (
|
| 112 |
+
"revenue_usd_billions 2023-Q1 to 2024-Q4: $69.8B -> $96.5B. "
|
| 113 |
+
"Recent QoQ 5.4%, recent YoY 11.8%.",
|
| 114 |
+
8,
|
| 115 |
+
),
|
| 116 |
+
"librarian_rag_tool": (
|
| 117 |
+
"Item 1A Risk Factors - intense competition in AI, search and cloud; "
|
| 118 |
+
"rapid model commoditisation; regulatory pressure in the EU.",
|
| 119 |
+
5,
|
| 120 |
+
),
|
| 121 |
+
"scout_web_search_tool": (
|
| 122 |
+
"GOOGL last close $178.42, +1.2% on the session.",
|
| 123 |
+
3,
|
| 124 |
+
),
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
ANSWER_REPLAN = (
|
| 128 |
+
"Alphabet's revenue grew from $307.4B in FY2023 to $350.0B in FY2024, a 13.9% increase, "
|
| 129 |
+
"with quarterly revenue rising from $69.8B in Q1 2023 to $96.5B in Q4 2024. Growth has been "
|
| 130 |
+
"steady rather than accelerating: recent QoQ growth of 5.4% against YoY of 11.8% suggests the "
|
| 131 |
+
"annual rate is being sustained by consistent sequential gains, not a single outlier quarter.\n\n"
|
| 132 |
+
"The 10-K's competitive risk disclosures read as a direct commentary on that trajectory. "
|
| 133 |
+
"Management flags intense competition across search, cloud and AI, and specifically the risk "
|
| 134 |
+
"that model capability becomes commoditised. A data-grounded hypothesis: the revenue line has "
|
| 135 |
+
"so far absorbed those pressures because Cloud is compounding off a smaller base while Search "
|
| 136 |
+
"monetisation holds, but the margin story is where competition would show up first. Rising "
|
| 137 |
+
"capital expenditure on AI infrastructure alongside only moderate sequential revenue growth "
|
| 138 |
+
"is the pattern to watch.\n\n"
|
| 139 |
+
"Caveat: this reads growth and risk disclosure side by side. The filing does not attribute "
|
| 140 |
+
"any revenue movement to competitive dynamics, so the causal link is inference, not disclosure."
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
ANSWER_SIMPLE = (
|
| 144 |
+
"Alphabet reported total revenue of $350.0B for FY2024, up from $307.4B in FY2023 — "
|
| 145 |
+
"growth of 13.9% year over year. Quarterly revenue reached $96.5B in Q4 2024, the highest "
|
| 146 |
+
"in the series, continuing an unbroken sequence of sequential increases through the period.\n\n"
|
| 147 |
+
"Source: Alphabet Inc. FY2024 10-K, consolidated statements of income."
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def _stream_tokens(run: _Run, text: str) -> AsyncIterator[str]:
|
| 152 |
+
"""Emit `text` in word-ish chunks at the measured single-stream rate."""
|
| 153 |
+
chunks = re.findall(r"\S+\s*", text)
|
| 154 |
+
for chunk in chunks:
|
| 155 |
+
await run.sleep(SECONDS_PER_TOKEN)
|
| 156 |
+
run.output_tokens += 1
|
| 157 |
+
yield sse(Token(seq=run.seq(), text=chunk))
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def _tool_call(run: _Run, tool_name: str, tool_input: str) -> AsyncIterator[str]:
|
| 161 |
+
seconds = TOOL_S.get(tool_name, 1.5)
|
| 162 |
+
preview, count = TOOL_RESULTS.get(tool_name, ("(no result)", 0))
|
| 163 |
+
yield sse(ToolStart(seq=run.seq(), tool_name=tool_name, tool_input=tool_input))
|
| 164 |
+
await run.sleep(seconds)
|
| 165 |
+
# Timing is recorded by the caller's `node_end` for tool_executor; adding it
|
| 166 |
+
# here as well would double count.
|
| 167 |
+
yield sse(
|
| 168 |
+
ToolEnd(
|
| 169 |
+
seq=run.seq(),
|
| 170 |
+
tool_name=tool_name,
|
| 171 |
+
duration_ms=int(seconds * 1000),
|
| 172 |
+
ok=True,
|
| 173 |
+
preview=preview,
|
| 174 |
+
result_count=count,
|
| 175 |
+
)
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
async def replay(query: str, conversation_id: str | None = None, speed: float = 1.0) -> AsyncIterator[str]:
|
| 180 |
+
run = _Run(query, conversation_id or f"conv_{uuid.uuid4().hex[:8]}", speed)
|
| 181 |
+
scenario = _pick_scenario(query)
|
| 182 |
+
|
| 183 |
+
try:
|
| 184 |
+
yield sse(
|
| 185 |
+
RunStart(
|
| 186 |
+
seq=run.seq(),
|
| 187 |
+
run_id=run.run_id,
|
| 188 |
+
conversation_id=run.conversation_id,
|
| 189 |
+
query=query,
|
| 190 |
+
)
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# --- Gatekeeper ------------------------------------------------------
|
| 194 |
+
yield run.node_start(NodeName.gatekeeper)
|
| 195 |
+
await run.sleep(GATEKEEPER_S)
|
| 196 |
+
run.llm_calls += 1
|
| 197 |
+
yield run.node_end(NodeName.gatekeeper, GATEKEEPER_S)
|
| 198 |
+
|
| 199 |
+
if scenario == "clarification":
|
| 200 |
+
yield sse(
|
| 201 |
+
Clarification(
|
| 202 |
+
seq=run.seq(),
|
| 203 |
+
question=(
|
| 204 |
+
"That's a little broad for me to answer well. Are you asking about "
|
| 205 |
+
"revenue and margin trends, the risk factors in the latest 10-K, or "
|
| 206 |
+
"current market performance?"
|
| 207 |
+
),
|
| 208 |
+
)
|
| 209 |
+
)
|
| 210 |
+
yield sse(
|
| 211 |
+
RunEnd(
|
| 212 |
+
seq=run.seq(),
|
| 213 |
+
run_id=run.run_id,
|
| 214 |
+
duration_ms=run.nominal_ms,
|
| 215 |
+
node_timings_ms=dict(run.node_timings_ms),
|
| 216 |
+
llm_calls=run.llm_calls,
|
| 217 |
+
output_tokens=run.output_tokens,
|
| 218 |
+
replans=run.replans,
|
| 219 |
+
)
|
| 220 |
+
)
|
| 221 |
+
return
|
| 222 |
+
|
| 223 |
+
if scenario == "replan":
|
| 224 |
+
# Attempt 1: the Planner reaches for SQL alone, which cannot speak to
|
| 225 |
+
# the qualitative half of the question. The Auditor catches it.
|
| 226 |
+
yield run.node_start(NodeName.planner, attempt=1)
|
| 227 |
+
await run.sleep(PLANNER_S)
|
| 228 |
+
run.llm_calls += 1
|
| 229 |
+
yield sse(
|
| 230 |
+
Plan(
|
| 231 |
+
seq=run.seq(),
|
| 232 |
+
attempt=1,
|
| 233 |
+
steps=[
|
| 234 |
+
PlanStep(tool_name="analyst_sql_tool", tool_input="total revenue FY2023 and FY2024"),
|
| 235 |
+
PlanStep(tool_name="FINISH", tool_input=""),
|
| 236 |
+
],
|
| 237 |
+
)
|
| 238 |
+
)
|
| 239 |
+
yield run.node_end(NodeName.planner, PLANNER_S, attempt=1)
|
| 240 |
+
|
| 241 |
+
yield run.node_start(NodeName.tool_executor, attempt=1)
|
| 242 |
+
async for frame in _tool_call(run, "analyst_sql_tool", "total revenue FY2023 and FY2024"):
|
| 243 |
+
yield frame
|
| 244 |
+
yield run.node_end(NodeName.tool_executor, TOOL_S["analyst_sql_tool"], attempt=1)
|
| 245 |
+
|
| 246 |
+
yield run.node_start(NodeName.auditor, attempt=1)
|
| 247 |
+
await run.sleep(AUDITOR_S)
|
| 248 |
+
run.llm_calls += 1
|
| 249 |
+
yield sse(
|
| 250 |
+
Audit(
|
| 251 |
+
seq=run.seq(),
|
| 252 |
+
attempt=1,
|
| 253 |
+
confidence_score=2,
|
| 254 |
+
is_relevant=True,
|
| 255 |
+
reasoning=(
|
| 256 |
+
"The revenue figures are accurate but address only half the request. "
|
| 257 |
+
"The user also asked about competitive risks in the 10-K, and no "
|
| 258 |
+
"retrieval over the filing was performed."
|
| 259 |
+
),
|
| 260 |
+
action="replan",
|
| 261 |
+
)
|
| 262 |
+
)
|
| 263 |
+
yield run.node_end(NodeName.auditor, AUDITOR_S, attempt=1)
|
| 264 |
+
run.replans += 1
|
| 265 |
+
|
| 266 |
+
# Attempt 2: replan with the Auditor's reasoning fed back in.
|
| 267 |
+
yield run.node_start(NodeName.planner, attempt=2)
|
| 268 |
+
await run.sleep(PLANNER_S)
|
| 269 |
+
run.llm_calls += 1
|
| 270 |
+
yield sse(
|
| 271 |
+
Plan(
|
| 272 |
+
seq=run.seq(),
|
| 273 |
+
attempt=2,
|
| 274 |
+
steps=[
|
| 275 |
+
PlanStep(tool_name="analyst_trend_tool", tool_input="revenue trend last two years"),
|
| 276 |
+
PlanStep(tool_name="librarian_rag_tool", tool_input="competitive risks in AI and cloud"),
|
| 277 |
+
PlanStep(tool_name="FINISH", tool_input=""),
|
| 278 |
+
],
|
| 279 |
+
)
|
| 280 |
+
)
|
| 281 |
+
yield run.node_end(NodeName.planner, PLANNER_S, attempt=2)
|
| 282 |
+
|
| 283 |
+
yield run.node_start(NodeName.tool_executor, attempt=2)
|
| 284 |
+
for name, tool_input in (
|
| 285 |
+
("analyst_trend_tool", "revenue trend last two years"),
|
| 286 |
+
("librarian_rag_tool", "competitive risks in AI and cloud"),
|
| 287 |
+
):
|
| 288 |
+
async for frame in _tool_call(run, name, tool_input):
|
| 289 |
+
yield frame
|
| 290 |
+
yield run.node_end(
|
| 291 |
+
NodeName.tool_executor,
|
| 292 |
+
TOOL_S["analyst_trend_tool"] + TOOL_S["librarian_rag_tool"],
|
| 293 |
+
attempt=2,
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
yield run.node_start(NodeName.auditor, attempt=2)
|
| 297 |
+
await run.sleep(AUDITOR_S)
|
| 298 |
+
run.llm_calls += 1
|
| 299 |
+
yield sse(
|
| 300 |
+
Audit(
|
| 301 |
+
seq=run.seq(),
|
| 302 |
+
attempt=2,
|
| 303 |
+
confidence_score=4,
|
| 304 |
+
is_relevant=True,
|
| 305 |
+
reasoning=(
|
| 306 |
+
"Both halves of the request are now covered: quantitative trend data "
|
| 307 |
+
"and the relevant risk-factor passages. Sufficient to synthesise."
|
| 308 |
+
),
|
| 309 |
+
action="accept",
|
| 310 |
+
)
|
| 311 |
+
)
|
| 312 |
+
yield run.node_end(NodeName.auditor, AUDITOR_S, attempt=2)
|
| 313 |
+
answer = ANSWER_REPLAN
|
| 314 |
+
else:
|
| 315 |
+
yield run.node_start(NodeName.planner)
|
| 316 |
+
await run.sleep(PLANNER_S)
|
| 317 |
+
run.llm_calls += 1
|
| 318 |
+
yield sse(
|
| 319 |
+
Plan(
|
| 320 |
+
seq=run.seq(),
|
| 321 |
+
steps=[
|
| 322 |
+
PlanStep(tool_name="analyst_sql_tool", tool_input=query),
|
| 323 |
+
PlanStep(tool_name="FINISH", tool_input=""),
|
| 324 |
+
],
|
| 325 |
+
)
|
| 326 |
+
)
|
| 327 |
+
yield run.node_end(NodeName.planner, PLANNER_S)
|
| 328 |
+
|
| 329 |
+
yield run.node_start(NodeName.tool_executor)
|
| 330 |
+
async for frame in _tool_call(run, "analyst_sql_tool", query):
|
| 331 |
+
yield frame
|
| 332 |
+
yield run.node_end(NodeName.tool_executor, TOOL_S["analyst_sql_tool"])
|
| 333 |
+
|
| 334 |
+
yield run.node_start(NodeName.auditor)
|
| 335 |
+
await run.sleep(AUDITOR_S)
|
| 336 |
+
run.llm_calls += 1
|
| 337 |
+
yield sse(
|
| 338 |
+
Audit(
|
| 339 |
+
seq=run.seq(),
|
| 340 |
+
confidence_score=4,
|
| 341 |
+
is_relevant=True,
|
| 342 |
+
reasoning="Tool output directly answers the request with figures from the filing.",
|
| 343 |
+
action="accept",
|
| 344 |
+
)
|
| 345 |
+
)
|
| 346 |
+
yield run.node_end(NodeName.auditor, AUDITOR_S)
|
| 347 |
+
answer = ANSWER_SIMPLE
|
| 348 |
+
|
| 349 |
+
# --- Synthesizer -----------------------------------------------------
|
| 350 |
+
yield run.node_start(NodeName.synthesizer)
|
| 351 |
+
run.llm_calls += 1
|
| 352 |
+
tokens_before = run.output_tokens
|
| 353 |
+
async for frame in _stream_tokens(run, answer):
|
| 354 |
+
yield frame
|
| 355 |
+
yield run.node_end(
|
| 356 |
+
NodeName.synthesizer,
|
| 357 |
+
(run.output_tokens - tokens_before) * SECONDS_PER_TOKEN,
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
yield sse(
|
| 361 |
+
RunEnd(
|
| 362 |
+
seq=run.seq(),
|
| 363 |
+
run_id=run.run_id,
|
| 364 |
+
duration_ms=run.nominal_ms,
|
| 365 |
+
node_timings_ms=dict(run.node_timings_ms),
|
| 366 |
+
llm_calls=run.llm_calls,
|
| 367 |
+
output_tokens=run.output_tokens,
|
| 368 |
+
replans=run.replans,
|
| 369 |
+
)
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
except asyncio.CancelledError:
|
| 373 |
+
# Client hung up. Nothing to clean up in the mock, but the real graph
|
| 374 |
+
# will need to cancel in-flight vLLM requests here.
|
| 375 |
+
raise
|
| 376 |
+
except Exception as exc: # pragma: no cover - defensive
|
| 377 |
+
yield sse(
|
| 378 |
+
Error(
|
| 379 |
+
seq=run.seq(),
|
| 380 |
+
code="mock_failure",
|
| 381 |
+
message=str(exc),
|
| 382 |
+
retryable=True,
|
| 383 |
+
)
|
| 384 |
+
)
|
backend/app/stream.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Translate LangGraph `astream_events` into the SSE event contract.
|
| 2 |
+
|
| 3 |
+
The first version of this adapter *predicted* the next node after each update.
|
| 4 |
+
After `verify` that guess is often wrong — the router and the adapter can
|
| 5 |
+
disagree about whether to replan, run another tool, or synthesise. Each wrong
|
| 6 |
+
guess emitted a `node_start synthesizer` the UI never closed, so the trace
|
| 7 |
+
showed "Synthesizer attempt 8 / running" while the graph was still on tools.
|
| 8 |
+
|
| 9 |
+
`astream_events` reports the node LangGraph actually entered, so a start is
|
| 10 |
+
only emitted for a real visit.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
import time
|
| 17 |
+
import uuid
|
| 18 |
+
from typing import Any, AsyncIterator, Dict, Optional
|
| 19 |
+
|
| 20 |
+
from .agent.graph import get_graph, run_config
|
| 21 |
+
from .config import get_settings
|
| 22 |
+
from .events import (
|
| 23 |
+
Audit,
|
| 24 |
+
Clarification,
|
| 25 |
+
Error,
|
| 26 |
+
Heartbeat,
|
| 27 |
+
NodeEnd,
|
| 28 |
+
NodeName,
|
| 29 |
+
NodeStart,
|
| 30 |
+
Plan,
|
| 31 |
+
PlanStep,
|
| 32 |
+
RunEnd,
|
| 33 |
+
RunStart,
|
| 34 |
+
Token,
|
| 35 |
+
ToolEnd,
|
| 36 |
+
ToolStart,
|
| 37 |
+
sse,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
GRAPH_TO_UI = {
|
| 41 |
+
"gatekeeper": NodeName.gatekeeper,
|
| 42 |
+
"planner": NodeName.planner,
|
| 43 |
+
"execute_tool": NodeName.tool_executor,
|
| 44 |
+
"verify": NodeName.auditor,
|
| 45 |
+
"synthesize": NodeName.synthesizer,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
PING_S = 5.0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _preview(output: Any, width: int = 240) -> str:
|
| 52 |
+
text = output if isinstance(output, str) else str(output)
|
| 53 |
+
return " ".join(text.split())[:width]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _node_name(event: Dict[str, Any]) -> Optional[str]:
|
| 57 |
+
"""Return a supervisor node name, or None for nested LLM/tool events."""
|
| 58 |
+
name = event.get("name")
|
| 59 |
+
node = (event.get("metadata") or {}).get("langgraph_node")
|
| 60 |
+
if name in GRAPH_TO_UI and node == name:
|
| 61 |
+
return name
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def live_stream(query: str, conversation_id: Optional[str] = None) -> AsyncIterator[str]:
|
| 66 |
+
"""Yield SSE frames for one live supervisor run."""
|
| 67 |
+
settings = get_settings()
|
| 68 |
+
seq = 0
|
| 69 |
+
started = time.perf_counter()
|
| 70 |
+
last_mark = started
|
| 71 |
+
deadline = started + settings.agent_timeout_s
|
| 72 |
+
node_timings_ms: Dict[str, int] = {}
|
| 73 |
+
attempts: Dict[str, int] = {}
|
| 74 |
+
llm_calls = 0
|
| 75 |
+
output_tokens = 0
|
| 76 |
+
streamed_tokens = False
|
| 77 |
+
replan_count = 0
|
| 78 |
+
run_id = f"run_{uuid.uuid4().hex[:12]}"
|
| 79 |
+
conversation_id = conversation_id or f"conv_{uuid.uuid4().hex[:12]}"
|
| 80 |
+
|
| 81 |
+
def emit(event) -> str:
|
| 82 |
+
nonlocal seq
|
| 83 |
+
seq += 1
|
| 84 |
+
event.seq = seq
|
| 85 |
+
return sse(event)
|
| 86 |
+
|
| 87 |
+
yield emit(RunStart(seq=0, run_id=run_id, conversation_id=conversation_id, query=query))
|
| 88 |
+
|
| 89 |
+
iterator = get_graph().astream_events(
|
| 90 |
+
{"original_request": query, "replan_count": 0},
|
| 91 |
+
run_config(),
|
| 92 |
+
version="v2",
|
| 93 |
+
).__aiter__()
|
| 94 |
+
|
| 95 |
+
# wait_for() cancels its awaitable on timeout. Cancelling anext() closes
|
| 96 |
+
# the async generator, so a RunPod call that takes >PING_S would abort the
|
| 97 |
+
# whole graph. Shield the next event and reuse the same task across pings.
|
| 98 |
+
pending: Optional[asyncio.Task] = None
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
while True:
|
| 102 |
+
remaining = deadline - time.perf_counter()
|
| 103 |
+
if remaining <= 0:
|
| 104 |
+
if pending is not None:
|
| 105 |
+
pending.cancel()
|
| 106 |
+
yield emit(
|
| 107 |
+
Error(
|
| 108 |
+
seq=0,
|
| 109 |
+
code="timeout",
|
| 110 |
+
message=f"Agent exceeded its {settings.agent_timeout_s:.0f}s ceiling.",
|
| 111 |
+
retryable=True,
|
| 112 |
+
)
|
| 113 |
+
)
|
| 114 |
+
return
|
| 115 |
+
if pending is None:
|
| 116 |
+
pending = asyncio.ensure_future(anext(iterator))
|
| 117 |
+
try:
|
| 118 |
+
event = await asyncio.wait_for(
|
| 119 |
+
asyncio.shield(pending), timeout=min(PING_S, remaining)
|
| 120 |
+
)
|
| 121 |
+
pending = None
|
| 122 |
+
except asyncio.TimeoutError:
|
| 123 |
+
if time.perf_counter() >= deadline:
|
| 124 |
+
pending.cancel()
|
| 125 |
+
yield emit(
|
| 126 |
+
Error(
|
| 127 |
+
seq=0,
|
| 128 |
+
code="timeout",
|
| 129 |
+
message=f"Agent exceeded its {settings.agent_timeout_s:.0f}s ceiling.",
|
| 130 |
+
retryable=True,
|
| 131 |
+
)
|
| 132 |
+
)
|
| 133 |
+
return
|
| 134 |
+
yield emit(Heartbeat(seq=0))
|
| 135 |
+
continue
|
| 136 |
+
except StopAsyncIteration:
|
| 137 |
+
pending = None
|
| 138 |
+
break
|
| 139 |
+
|
| 140 |
+
kind = event.get("event")
|
| 141 |
+
graph_node = _node_name(event)
|
| 142 |
+
|
| 143 |
+
if (
|
| 144 |
+
kind == "on_chat_model_stream"
|
| 145 |
+
and (event.get("metadata") or {}).get("langgraph_node") == "synthesize"
|
| 146 |
+
):
|
| 147 |
+
chunk = (event.get("data") or {}).get("chunk")
|
| 148 |
+
text = getattr(chunk, "content", None) or ""
|
| 149 |
+
if isinstance(text, list):
|
| 150 |
+
text = "".join(
|
| 151 |
+
part.get("text", "") if isinstance(part, dict) else str(part)
|
| 152 |
+
for part in text
|
| 153 |
+
)
|
| 154 |
+
if text:
|
| 155 |
+
streamed_tokens = True
|
| 156 |
+
output_tokens += 1
|
| 157 |
+
yield emit(Token(seq=0, text=text))
|
| 158 |
+
continue
|
| 159 |
+
|
| 160 |
+
if graph_node is None:
|
| 161 |
+
continue
|
| 162 |
+
|
| 163 |
+
ui_node = GRAPH_TO_UI[graph_node]
|
| 164 |
+
|
| 165 |
+
if kind == "on_chain_start":
|
| 166 |
+
attempts[graph_node] = attempts.get(graph_node, 0) + 1
|
| 167 |
+
last_mark = time.perf_counter()
|
| 168 |
+
yield emit(
|
| 169 |
+
NodeStart(seq=0, node=ui_node, attempt=attempts[graph_node])
|
| 170 |
+
)
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
if kind != "on_chain_end":
|
| 174 |
+
continue
|
| 175 |
+
|
| 176 |
+
now = time.perf_counter()
|
| 177 |
+
duration_ms = int((now - last_mark) * 1000)
|
| 178 |
+
node_timings_ms[ui_node.value] = (
|
| 179 |
+
node_timings_ms.get(ui_node.value, 0) + duration_ms
|
| 180 |
+
)
|
| 181 |
+
payload = (event.get("data") or {}).get("output") or {}
|
| 182 |
+
if not isinstance(payload, dict):
|
| 183 |
+
payload = {}
|
| 184 |
+
|
| 185 |
+
yield emit(
|
| 186 |
+
NodeEnd(
|
| 187 |
+
seq=0,
|
| 188 |
+
node=ui_node,
|
| 189 |
+
attempt=attempts.get(graph_node, 1),
|
| 190 |
+
duration_ms=duration_ms,
|
| 191 |
+
)
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
if graph_node == "gatekeeper":
|
| 195 |
+
llm_calls += 1
|
| 196 |
+
question = payload.get("clarification_question")
|
| 197 |
+
if question:
|
| 198 |
+
yield emit(Clarification(seq=0, question=question))
|
| 199 |
+
scoped = payload.get("final_response")
|
| 200 |
+
if scoped:
|
| 201 |
+
streamed_tokens = True
|
| 202 |
+
output_tokens = len(str(scoped).split())
|
| 203 |
+
yield emit(Token(seq=0, text=str(scoped)))
|
| 204 |
+
|
| 205 |
+
elif graph_node == "planner":
|
| 206 |
+
llm_calls += 1
|
| 207 |
+
replan_count = int(payload.get("replan_count") or replan_count)
|
| 208 |
+
steps = [
|
| 209 |
+
PlanStep(
|
| 210 |
+
tool_name=s["tool_name"],
|
| 211 |
+
tool_input=s.get("tool_input") or "",
|
| 212 |
+
)
|
| 213 |
+
for s in payload.get("plan") or []
|
| 214 |
+
if isinstance(s, dict) and s.get("tool_name") != "FINISH"
|
| 215 |
+
]
|
| 216 |
+
yield emit(
|
| 217 |
+
Plan(seq=0, attempt=attempts.get(graph_node, 1), steps=steps)
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
elif graph_node == "execute_tool":
|
| 221 |
+
steps = payload.get("intermediate_steps") or []
|
| 222 |
+
if steps:
|
| 223 |
+
last = steps[-1]
|
| 224 |
+
output = last.get("tool_output")
|
| 225 |
+
failed = isinstance(output, str) and (
|
| 226 |
+
output.startswith("Tool raised")
|
| 227 |
+
or output.startswith("Unknown tool")
|
| 228 |
+
)
|
| 229 |
+
yield emit(
|
| 230 |
+
ToolStart(
|
| 231 |
+
seq=0,
|
| 232 |
+
tool_name=last.get("tool_name") or "unknown",
|
| 233 |
+
tool_input=last.get("tool_input") or "",
|
| 234 |
+
)
|
| 235 |
+
)
|
| 236 |
+
yield emit(
|
| 237 |
+
ToolEnd(
|
| 238 |
+
seq=0,
|
| 239 |
+
tool_name=last.get("tool_name") or "unknown",
|
| 240 |
+
duration_ms=duration_ms,
|
| 241 |
+
ok=not failed,
|
| 242 |
+
preview=_preview(output),
|
| 243 |
+
result_count=len(output) if isinstance(output, list) else None,
|
| 244 |
+
error=(str(output)[:200] if failed else None),
|
| 245 |
+
)
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
elif graph_node == "verify":
|
| 249 |
+
llm_calls += 1
|
| 250 |
+
history = payload.get("verification_history") or []
|
| 251 |
+
if history:
|
| 252 |
+
audit = history[-1]
|
| 253 |
+
score = int(audit.get("confidence_score") or 1)
|
| 254 |
+
action = (
|
| 255 |
+
"replan" if score < settings.audit_pass_score else "accept"
|
| 256 |
+
)
|
| 257 |
+
yield emit(
|
| 258 |
+
Audit(
|
| 259 |
+
seq=0,
|
| 260 |
+
attempt=attempts.get(graph_node, 1),
|
| 261 |
+
confidence_score=score,
|
| 262 |
+
is_relevant=bool(audit.get("is_relevant")),
|
| 263 |
+
reasoning=str(audit.get("reasoning") or ""),
|
| 264 |
+
action=action,
|
| 265 |
+
)
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
elif graph_node == "synthesize":
|
| 269 |
+
llm_calls += 1
|
| 270 |
+
if not streamed_tokens:
|
| 271 |
+
answer = payload.get("final_response") or ""
|
| 272 |
+
output_tokens = len(str(answer).split())
|
| 273 |
+
if answer:
|
| 274 |
+
yield emit(Token(seq=0, text=str(answer)))
|
| 275 |
+
|
| 276 |
+
except asyncio.CancelledError:
|
| 277 |
+
raise
|
| 278 |
+
except Exception as exc: # noqa: BLE001 - the client needs the error on the stream
|
| 279 |
+
yield emit(
|
| 280 |
+
Error(seq=0, code=type(exc).__name__, message=str(exc)[:400], retryable=True)
|
| 281 |
+
)
|
| 282 |
+
return
|
| 283 |
+
|
| 284 |
+
yield emit(
|
| 285 |
+
RunEnd(
|
| 286 |
+
seq=0,
|
| 287 |
+
run_id=run_id,
|
| 288 |
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
| 289 |
+
node_timings_ms=node_timings_ms,
|
| 290 |
+
llm_calls=llm_calls,
|
| 291 |
+
output_tokens=output_tokens,
|
| 292 |
+
replans=replan_count,
|
| 293 |
+
)
|
| 294 |
+
)
|
backend/check_retrieval.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vector-only retrieval check. Avoids the reranker and LLM rewrite, which
|
| 2 |
+
together with the embedded Qdrant client have been crashing this process
|
| 3 |
+
(0xC0000005) on Windows.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from backend.app.agent.embeddings import get_embeddings # noqa: E402
|
| 14 |
+
from backend.app.agent.resources import get_qdrant # noqa: E402
|
| 15 |
+
from backend.app.config import get_settings # noqa: E402
|
| 16 |
+
|
| 17 |
+
QUERIES = [
|
| 18 |
+
"What are the main competitive risks Alphabet identifies in AI and cloud?",
|
| 19 |
+
"antitrust lawsuits Google Search advertising",
|
| 20 |
+
"cybersecurity risk management and incidents",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main() -> None:
|
| 25 |
+
settings = get_settings()
|
| 26 |
+
embedder = get_embeddings()
|
| 27 |
+
client = get_qdrant()
|
| 28 |
+
print(f"collection {settings.collection_name}: "
|
| 29 |
+
f"{client.get_collection(settings.collection_name).points_count} points\n")
|
| 30 |
+
|
| 31 |
+
for query in QUERIES:
|
| 32 |
+
print(f"Q: {query}")
|
| 33 |
+
hits = client.query_points(
|
| 34 |
+
collection_name=settings.collection_name,
|
| 35 |
+
query=embedder.embed_query(query),
|
| 36 |
+
limit=5,
|
| 37 |
+
with_payload=True,
|
| 38 |
+
).points
|
| 39 |
+
for rank, hit in enumerate(hits, 1):
|
| 40 |
+
payload = hit.payload or {}
|
| 41 |
+
section = payload.get("section") or "(none)"
|
| 42 |
+
summary = " ".join(str(payload.get("summary") or payload.get("content") or "").split())
|
| 43 |
+
print(f" {rank}. {hit.score:.4f} [{section}] {summary[:140]}")
|
| 44 |
+
print()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
main()
|
backend/diagnose_corpus.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""How much of the 10-K actually made it into the index?
|
| 2 |
+
|
| 3 |
+
Retrieval for risk-factor questions kept returning business-description content,
|
| 4 |
+
which suggests the chunking dropped sections rather than that the embeddings are
|
| 5 |
+
weak. Compares indexed text against the source filing.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
CHUNKS = ROOT / "data" / "enriched_chunks.json"
|
| 15 |
+
SOURCE = ROOT / "data" / "alphabet_10k_submission.txt"
|
| 16 |
+
|
| 17 |
+
TERMS = [
|
| 18 |
+
"risk factors",
|
| 19 |
+
"item 1a",
|
| 20 |
+
"competition",
|
| 21 |
+
"antitrust",
|
| 22 |
+
"adversely affect",
|
| 23 |
+
"could harm",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main() -> None:
|
| 28 |
+
chunks = json.loads(CHUNKS.read_text(encoding="utf-8"))
|
| 29 |
+
indexed = " ".join(str(c.get("content", "")) for c in chunks)
|
| 30 |
+
source = SOURCE.read_text(encoding="utf-8", errors="ignore")
|
| 31 |
+
|
| 32 |
+
print(f"source file {SOURCE.stat().st_size:,} bytes")
|
| 33 |
+
print(f"source text {len(source):,} chars")
|
| 34 |
+
print(f"indexed text {len(indexed):,} chars across {len(chunks)} chunks")
|
| 35 |
+
print(f"coverage {len(indexed) / len(source) * 100:.1f}% of source")
|
| 36 |
+
print(f"avg chunk {len(indexed) // len(chunks):,} chars")
|
| 37 |
+
|
| 38 |
+
lower_indexed = indexed.lower()
|
| 39 |
+
lower_source = source.lower()
|
| 40 |
+
print(f"\n{'term':<20} {'indexed':>8} {'source':>8}")
|
| 41 |
+
for term in TERMS:
|
| 42 |
+
print(f"{term:<20} {lower_indexed.count(term):>8} {lower_source.count(term):>8}")
|
| 43 |
+
|
| 44 |
+
sources = {c.get("source") for c in chunks}
|
| 45 |
+
print(f"\ndistinct payload sources: {sources}")
|
| 46 |
+
tables = sum(1 for c in chunks if c.get("is_table"))
|
| 47 |
+
print(f"chunks flagged as tables: {tables}/{len(chunks)}")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
main()
|
backend/diagnose_retrieval.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Is the CrossEncoder rerank helping or hurting?
|
| 2 |
+
|
| 3 |
+
The agent's Auditor rejected the Librarian's 10-K output as irrelevant while a
|
| 4 |
+
direct vector query on the same topic returned excellent hits. That points at
|
| 5 |
+
something between the two: the LLM query rewrite, or the rerank.
|
| 6 |
+
|
| 7 |
+
Prints raw vector order against reranked order so the two are directly
|
| 8 |
+
comparable. Relevant to the README's unverified "30% Top-K precision
|
| 9 |
+
improvement" claim.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 19 |
+
|
| 20 |
+
from backend.app.agent.embeddings import get_embeddings # noqa: E402
|
| 21 |
+
from backend.app.agent.resources import get_qdrant, get_reranker # noqa: E402
|
| 22 |
+
from backend.app.agent.tools import optimize_query # noqa: E402
|
| 23 |
+
from backend.app.config import get_settings # noqa: E402
|
| 24 |
+
|
| 25 |
+
QUERY = "What are the main competitive risks Alphabet identifies in AI and cloud?"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def snippet(payload, width=110):
|
| 29 |
+
text = payload.get("summary") or payload.get("content") or ""
|
| 30 |
+
section = payload.get("section") or "?"
|
| 31 |
+
return f"[{section}] " + " ".join(str(text).split())[:width]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def main():
|
| 35 |
+
settings = get_settings()
|
| 36 |
+
embedder = get_embeddings()
|
| 37 |
+
client = get_qdrant()
|
| 38 |
+
|
| 39 |
+
rewritten = await optimize_query(QUERY)
|
| 40 |
+
print(f"original: {QUERY}")
|
| 41 |
+
print(f"rewritten: {rewritten}\n")
|
| 42 |
+
|
| 43 |
+
for label, query_text in (("ORIGINAL", QUERY), ("REWRITTEN", rewritten)):
|
| 44 |
+
hits = client.query_points(
|
| 45 |
+
collection_name=settings.collection_name,
|
| 46 |
+
query=embedder.embed_query(query_text),
|
| 47 |
+
limit=settings.retrieval_candidates,
|
| 48 |
+
with_payload=True,
|
| 49 |
+
).points
|
| 50 |
+
|
| 51 |
+
print(f"=== {label} ===")
|
| 52 |
+
print(" vector top 5:")
|
| 53 |
+
for rank, hit in enumerate(hits[:5], 1):
|
| 54 |
+
print(f" {rank}. {hit.score:.4f} {snippet(hit.payload)}")
|
| 55 |
+
|
| 56 |
+
pairs = [[query_text, h.payload.get("content", "")] for h in hits]
|
| 57 |
+
scores = get_reranker().predict(pairs)
|
| 58 |
+
ranked = sorted(zip(hits, scores), key=lambda p: p[1], reverse=True)
|
| 59 |
+
|
| 60 |
+
print(" reranked top 5:")
|
| 61 |
+
for rank, (hit, score) in enumerate(ranked[:5], 1):
|
| 62 |
+
moved = hits.index(hit) + 1
|
| 63 |
+
print(f" {rank}. {score:+.3f} (was #{moved}) {snippet(hit.payload)}")
|
| 64 |
+
print()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
if __name__ == "__main__":
|
| 68 |
+
asyncio.run(main())
|
backend/ingest/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
backend/ingest/enrich.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add search metadata to a chunk corpus, using Qwen on vLLM.
|
| 2 |
+
|
| 3 |
+
Runs as a separate process from `pipeline.py` on purpose. Doing both in one
|
| 4 |
+
process crashed the interpreter (0xC0000005) as soon as enrichment started: the
|
| 5 |
+
lxml/unstructured object graph for a 2 MB filing stays alive while async HTTP
|
| 6 |
+
work begins, and the combination is unstable on this platform. Splitting the
|
| 7 |
+
stages also means a failed enrichment run never costs the parse again.
|
| 8 |
+
|
| 9 |
+
Enrichment is best-effort by design. `enrich_chunk.py`, which this replaces,
|
| 10 |
+
dropped any chunk whose API call failed:
|
| 11 |
+
|
| 12 |
+
if enrichment_data:
|
| 13 |
+
all_enriched_chunks.append(...)
|
| 14 |
+
|
| 15 |
+
Here a failure leaves the chunk in place with `enriched: false` and an
|
| 16 |
+
`enrich_error` note, so content is never lost to a transient error. Progress is
|
| 17 |
+
flushed to disk periodically for the same reason.
|
| 18 |
+
|
| 19 |
+
python backend/ingest/enrich.py --limit 8 # sample
|
| 20 |
+
python backend/ingest/enrich.py # all unenriched chunks
|
| 21 |
+
python backend/ingest/enrich.py --redo # re-enrich everything
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import argparse
|
| 27 |
+
import asyncio
|
| 28 |
+
import json
|
| 29 |
+
import random
|
| 30 |
+
import sys
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Any, Dict, List
|
| 33 |
+
|
| 34 |
+
from pydantic import BaseModel, Field
|
| 35 |
+
|
| 36 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 37 |
+
|
| 38 |
+
CORPUS = Path(__file__).resolve().parents[2] / "data" / "chunks_v2.json"
|
| 39 |
+
SAVE_EVERY = 25
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ChunkMetadata(BaseModel):
|
| 43 |
+
"""Enrichment fields.
|
| 44 |
+
|
| 45 |
+
Deliberately flat, with no optional members. Qwen2.5-7B follows a small
|
| 46 |
+
all-required schema far more reliably under constrained decoding, and a
|
| 47 |
+
table's summary belongs in `summary` anyway.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
summary: str = Field(description="One or two sentences describing the content.")
|
| 51 |
+
keywords: List[str] = Field(description="Five to seven key topics or entities.")
|
| 52 |
+
hypothetical_questions: List[str] = Field(
|
| 53 |
+
description="Three to five questions this content answers."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# The summary is embedded alongside the content, so it must read as a statement
|
| 58 |
+
# about Alphabet, not about the document. Qwen's default register is
|
| 59 |
+
# meta-commentary ("The provided excerpt does not contain...") which pollutes the
|
| 60 |
+
# vector with document-description language that no user query resembles.
|
| 61 |
+
_NO_META = """Write about Alphabet's business itself, never about this text.
|
| 62 |
+
Never begin with or include phrases like "The excerpt", "This chunk", "This
|
| 63 |
+
table", "The provided", "The document" or "This section". State the substance
|
| 64 |
+
directly. Add no facts that are not present. If the content is only
|
| 65 |
+
administrative boilerplate, say what it covers in one short clause."""
|
| 66 |
+
|
| 67 |
+
PROSE_PROMPT = f"""You are indexing Alphabet's 10-K for a financial analyst's search tool.
|
| 68 |
+
|
| 69 |
+
{_NO_META}
|
| 70 |
+
|
| 71 |
+
Phrase the questions the way an analyst would actually type them.
|
| 72 |
+
|
| 73 |
+
Content:
|
| 74 |
+
---
|
| 75 |
+
{{content}}
|
| 76 |
+
---"""
|
| 77 |
+
|
| 78 |
+
TABLE_PROMPT = f"""You are indexing a TABLE from Alphabet's 10-K for a financial
|
| 79 |
+
analyst's search tool.
|
| 80 |
+
|
| 81 |
+
{_NO_META}
|
| 82 |
+
|
| 83 |
+
Name the line items and periods, and state any trend in the figures, for
|
| 84 |
+
example: "Google Cloud revenue grew from $33.1B in 2023 to $43.2B in 2024."
|
| 85 |
+
Do not invent figures.
|
| 86 |
+
|
| 87 |
+
Content:
|
| 88 |
+
---
|
| 89 |
+
{{content}}
|
| 90 |
+
---"""
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def enrich_one(record: Dict[str, Any], model, attempts: int) -> bool:
|
| 94 |
+
"""Fill enrichment fields in place. Returns success; never raises."""
|
| 95 |
+
body = str(record.get("content") or record.get("content_html") or "")
|
| 96 |
+
if len(body.strip()) < 40:
|
| 97 |
+
record["enrich_error"] = "content too short to enrich"
|
| 98 |
+
return False
|
| 99 |
+
|
| 100 |
+
template = TABLE_PROMPT if record.get("is_table") else PROSE_PROMPT
|
| 101 |
+
prompt = template.format(content=body[:4000])
|
| 102 |
+
|
| 103 |
+
for attempt in range(1, attempts + 1):
|
| 104 |
+
try:
|
| 105 |
+
result: ChunkMetadata = await model.ainvoke(prompt)
|
| 106 |
+
record["summary"] = result.summary.strip()
|
| 107 |
+
record["keywords"] = [k.strip() for k in result.keywords if k.strip()]
|
| 108 |
+
record["hypothetical_questions"] = [
|
| 109 |
+
q.strip() for q in result.hypothetical_questions if q.strip()
|
| 110 |
+
]
|
| 111 |
+
record["enriched"] = True
|
| 112 |
+
record.pop("enrich_error", None)
|
| 113 |
+
return True
|
| 114 |
+
except Exception as exc: # noqa: BLE001 - enrichment is best-effort
|
| 115 |
+
if attempt == attempts:
|
| 116 |
+
record["enrich_error"] = f"{type(exc).__name__}: {exc}"[:200]
|
| 117 |
+
return False
|
| 118 |
+
# Jittered backoff so a burst of failures does not retry in lockstep.
|
| 119 |
+
await asyncio.sleep(min(2**attempt, 8) + random.random())
|
| 120 |
+
return False
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
async def run(
|
| 124 |
+
records: List[Dict[str, Any]],
|
| 125 |
+
targets: List[Dict[str, Any]],
|
| 126 |
+
path: Path,
|
| 127 |
+
concurrency: int,
|
| 128 |
+
attempts: int,
|
| 129 |
+
) -> None:
|
| 130 |
+
from backend.app.agent.llm import structured_model
|
| 131 |
+
|
| 132 |
+
model = structured_model(ChunkMetadata, temperature=0.1)
|
| 133 |
+
semaphore = asyncio.Semaphore(concurrency)
|
| 134 |
+
lock = asyncio.Lock()
|
| 135 |
+
total = len(targets)
|
| 136 |
+
done = 0
|
| 137 |
+
failed = 0
|
| 138 |
+
|
| 139 |
+
def save() -> None:
|
| 140 |
+
path.write_text(json.dumps(records, indent=2), encoding="utf-8")
|
| 141 |
+
|
| 142 |
+
async def worker(record: Dict[str, Any]) -> None:
|
| 143 |
+
nonlocal done, failed
|
| 144 |
+
async with semaphore:
|
| 145 |
+
ok = await enrich_one(record, model, attempts)
|
| 146 |
+
async with lock:
|
| 147 |
+
done += 1
|
| 148 |
+
if not ok:
|
| 149 |
+
failed += 1
|
| 150 |
+
if done % SAVE_EVERY == 0 or done == total:
|
| 151 |
+
save()
|
| 152 |
+
print(f" {done}/{total} done, {failed} failed (saved)", flush=True)
|
| 153 |
+
|
| 154 |
+
await asyncio.gather(*(worker(record) for record in targets))
|
| 155 |
+
save()
|
| 156 |
+
print(f"\nEnriched {total - failed}/{total}.")
|
| 157 |
+
if failed:
|
| 158 |
+
print(f"{failed} chunks kept with content but no metadata (enriched=false)")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def main() -> int:
|
| 162 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 163 |
+
parser.add_argument("--corpus", type=Path, default=CORPUS)
|
| 164 |
+
parser.add_argument("--limit", type=int, help="Enrich at most N chunks")
|
| 165 |
+
parser.add_argument("--section", help="Only chunks from this section")
|
| 166 |
+
parser.add_argument("--redo", action="store_true", help="Re-enrich already-done chunks")
|
| 167 |
+
parser.add_argument("--concurrency", type=int, default=6)
|
| 168 |
+
parser.add_argument("--attempts", type=int, default=3)
|
| 169 |
+
args = parser.parse_args()
|
| 170 |
+
|
| 171 |
+
records = json.loads(args.corpus.read_text(encoding="utf-8"))
|
| 172 |
+
targets = records if args.redo else [r for r in records if not r.get("enriched")]
|
| 173 |
+
if args.section:
|
| 174 |
+
targets = [r for r in targets if r.get("section") == args.section]
|
| 175 |
+
if args.limit:
|
| 176 |
+
targets = targets[: args.limit]
|
| 177 |
+
|
| 178 |
+
already = sum(1 for r in records if r.get("enriched"))
|
| 179 |
+
print(f"corpus {args.corpus.name}: {len(records)} chunks, {already} already enriched")
|
| 180 |
+
print(f"to enrich {len(targets)} (concurrency {args.concurrency}, {args.attempts} attempts)")
|
| 181 |
+
if not targets:
|
| 182 |
+
print("Nothing to do.")
|
| 183 |
+
return 0
|
| 184 |
+
|
| 185 |
+
asyncio.run(run(records, targets, args.corpus, args.concurrency, args.attempts))
|
| 186 |
+
return 0
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
if __name__ == "__main__":
|
| 190 |
+
raise SystemExit(main())
|
backend/ingest/extract.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extract readable 10-K text from the inline-XBRL filing.
|
| 2 |
+
|
| 3 |
+
The source is a single iXBRL document (Workiva-generated), not an SGML
|
| 4 |
+
submission wrapper. That matters for two reasons:
|
| 5 |
+
|
| 6 |
+
1. Most of its 2.5 MB is markup and XBRL facts, so measuring index coverage
|
| 7 |
+
against raw file size badly understates it. `visible_text()` gives the honest
|
| 8 |
+
denominator.
|
| 9 |
+
2. `<ix:header>` holds the hidden fact store — thousands of tagged values plus,
|
| 10 |
+
often, entire narrative sections duplicated inside TextBlock facts. Feeding it
|
| 11 |
+
to a chunker produces duplicate and non-prose chunks, so it is stripped.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
import warnings
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Dict, List, Tuple
|
| 20 |
+
|
| 21 |
+
from bs4 import BeautifulSoup, Comment, XMLParsedAsHTMLWarning
|
| 22 |
+
|
| 23 |
+
# The filing opens with an XML declaration but is HTML in practice; the HTML
|
| 24 |
+
# parser is what handles its malformed markup correctly.
|
| 25 |
+
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
| 26 |
+
|
| 27 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
| 28 |
+
DEFAULT_SOURCE = PROJECT_ROOT / "data" / "alphabet_10k_submission.txt"
|
| 29 |
+
|
| 30 |
+
# Canonical 10-K part/item headings, used to locate sections in the flat text.
|
| 31 |
+
ITEM_PATTERNS: List[Tuple[str, str]] = [
|
| 32 |
+
("item_1_business", r"item\s*1\.?\s*business"),
|
| 33 |
+
("item_1a_risk_factors", r"item\s*1a\.?\s*risk\s*factors"),
|
| 34 |
+
("item_1b_unresolved", r"item\s*1b\.?\s*unresolved\s*staff\s*comments"),
|
| 35 |
+
("item_1c_cybersecurity", r"item\s*1c\.?\s*cybersecurity"),
|
| 36 |
+
("item_2_properties", r"item\s*2\.?\s*properties"),
|
| 37 |
+
("item_3_legal", r"item\s*3\.?\s*legal\s*proceedings"),
|
| 38 |
+
("item_5_market", r"item\s*5\.?\s*market\s*for"),
|
| 39 |
+
("item_7_mdna", r"item\s*7\.?\s*management.s\s*discussion"),
|
| 40 |
+
("item_7a_market_risk", r"item\s*7a\.?\s*quantitative"),
|
| 41 |
+
("item_8_financial", r"item\s*8\.?\s*financial\s*statements"),
|
| 42 |
+
("item_9a_controls", r"item\s*9a\.?\s*controls"),
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def load_soup(path: Path = DEFAULT_SOURCE) -> BeautifulSoup:
|
| 47 |
+
raw = path.read_text(encoding="utf-8", errors="ignore")
|
| 48 |
+
# lxml handles the iXBRL namespaces and malformed markup far faster than
|
| 49 |
+
# html.parser at this size.
|
| 50 |
+
return BeautifulSoup(raw, "lxml")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# Non-rendered iXBRL infrastructure: the fact store, plus the context and unit
|
| 54 |
+
# definitions in ix:resources. None of it is human-readable content, and leaving
|
| 55 |
+
# ix:resources in place inflated the coverage denominator by ~28k chars.
|
| 56 |
+
XBRL_PLUMBING = ("ix:header", "header", "ix:hidden", "hidden", "ix:resources", "ix:references")
|
| 57 |
+
|
| 58 |
+
# Rendered iXBRL tags. Their contents are part of the visible document, but
|
| 59 |
+
# `partition_html` does not descend into namespaced elements it does not know,
|
| 60 |
+
# so they must be unwrapped rather than kept. ix:continuation is the important
|
| 61 |
+
# one: it continues a text-block fact, and the legal-contingencies notes live
|
| 62 |
+
# inside it.
|
| 63 |
+
XBRL_INLINE_PREFIX = "ix:"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def strip_hidden(soup: BeautifulSoup) -> BeautifulSoup:
|
| 67 |
+
"""Remove the XBRL fact store, scripts, styles and comments."""
|
| 68 |
+
for tag_name in ("script", "style", "head"):
|
| 69 |
+
for tag in soup.find_all(tag_name):
|
| 70 |
+
tag.decompose()
|
| 71 |
+
|
| 72 |
+
for name in XBRL_PLUMBING:
|
| 73 |
+
for tag in soup.find_all(name):
|
| 74 |
+
tag.decompose()
|
| 75 |
+
|
| 76 |
+
for comment in soup.find_all(string=lambda s: isinstance(s, Comment)):
|
| 77 |
+
comment.extract()
|
| 78 |
+
|
| 79 |
+
# Elements explicitly hidden from rendering are XBRL plumbing, not content.
|
| 80 |
+
for tag in soup.find_all(style=re.compile(r"display\s*:\s*none", re.I)):
|
| 81 |
+
tag.decompose()
|
| 82 |
+
|
| 83 |
+
return soup
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def visible_text(soup: BeautifulSoup) -> str:
|
| 87 |
+
text = soup.get_text("\n")
|
| 88 |
+
text = re.sub(r"[ \t\u00a0]+", " ", text)
|
| 89 |
+
text = re.sub(r"\n\s*\n+", "\n\n", text)
|
| 90 |
+
return text.strip()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def unwrap_inline_xbrl(soup: BeautifulSoup) -> int:
|
| 94 |
+
"""Replace remaining ix:* tags with their children. Returns the count.
|
| 95 |
+
|
| 96 |
+
Without this, `partition_html` silently skips everything nested inside
|
| 97 |
+
ix:continuation — about a third of the filing's prose, including the
|
| 98 |
+
Antitrust Matters and cybersecurity discussions.
|
| 99 |
+
"""
|
| 100 |
+
removed = 0
|
| 101 |
+
for tag in soup.find_all(lambda t: t.name and t.name.startswith(XBRL_INLINE_PREFIX)):
|
| 102 |
+
tag.unwrap()
|
| 103 |
+
removed += 1
|
| 104 |
+
return removed
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def clean_html(path: Path = DEFAULT_SOURCE, verbose: bool = False) -> str:
|
| 108 |
+
"""HTML with XBRL plumbing removed, suitable for `partition_html`.
|
| 109 |
+
|
| 110 |
+
Tables are preserved — they carry the financial statements — so the chunker
|
| 111 |
+
can still detect and structure them.
|
| 112 |
+
"""
|
| 113 |
+
soup = strip_hidden(load_soup(path))
|
| 114 |
+
unwrapped = unwrap_inline_xbrl(soup)
|
| 115 |
+
if verbose:
|
| 116 |
+
print(f" unwrapped {unwrapped:,} inline iXBRL tags")
|
| 117 |
+
body = soup.body or soup
|
| 118 |
+
return str(body)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _greedy_pass(lowered: str, start: int) -> List[Tuple[str, int]]:
|
| 122 |
+
"""Match items in canonical order, each after the previous one."""
|
| 123 |
+
found: List[Tuple[str, int]] = []
|
| 124 |
+
cursor = start
|
| 125 |
+
for key, pattern in ITEM_PATTERNS:
|
| 126 |
+
match = re.compile(pattern).search(lowered, cursor)
|
| 127 |
+
if match:
|
| 128 |
+
found.append((key, match.start()))
|
| 129 |
+
cursor = match.end()
|
| 130 |
+
return found
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def find_sections(text: str) -> Dict[str, int]:
|
| 134 |
+
"""Character offsets of each 10-K item heading, in document order.
|
| 135 |
+
|
| 136 |
+
Neither "first match" nor "last match" works here. The table of contents
|
| 137 |
+
lists every item in the same order as the body, so first-match returns the
|
| 138 |
+
TOC; cross-references like "see Item 1A Risk Factors" appear throughout, so
|
| 139 |
+
last-match returns those and yields offsets that run backwards.
|
| 140 |
+
|
| 141 |
+
Instead, sweep for ordered runs of headings and keep the run that spans the
|
| 142 |
+
most text. The real sections are the only run covering the whole filing —
|
| 143 |
+
the TOC is packed into a couple of thousand characters.
|
| 144 |
+
"""
|
| 145 |
+
lowered = text.lower()
|
| 146 |
+
best: List[Tuple[str, int]] = []
|
| 147 |
+
best_span = -1
|
| 148 |
+
start = 0
|
| 149 |
+
|
| 150 |
+
while start < len(lowered):
|
| 151 |
+
run = _greedy_pass(lowered, start)
|
| 152 |
+
if len(run) < 2:
|
| 153 |
+
break
|
| 154 |
+
span = run[-1][1] - run[0][1]
|
| 155 |
+
if span > best_span:
|
| 156 |
+
best_span, best = span, run
|
| 157 |
+
start = run[0][1] + 1
|
| 158 |
+
|
| 159 |
+
return dict(best)
|
backend/ingest/pipeline.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Parse and chunk the filing into a corpus. No LLM calls.
|
| 2 |
+
|
| 3 |
+
Stage one of two; `enrich.py` adds search metadata afterwards. They are separate
|
| 4 |
+
processes because running both in one crashed the interpreter (0xC0000005) when
|
| 5 |
+
async HTTP work began while the lxml object graph was still alive.
|
| 6 |
+
|
| 7 |
+
Replaces the parsing half of `enrich_chunk.py`, which lost about a third of the
|
| 8 |
+
filing's prose and half its coverage:
|
| 9 |
+
|
| 10 |
+
* `partition_html` never descends into `<ix:continuation>`, the inline-XBRL tag
|
| 11 |
+
that continues a text-block fact. The Antitrust Matters and cybersecurity
|
| 12 |
+
discussions live inside it. `clean_html` now unwraps those tags, taking
|
| 13 |
+
coverage of readable text from 68% to 99%.
|
| 14 |
+
* Table chunks embed flattened text, not `text_as_html`. The old pipeline stored
|
| 15 |
+
raw HTML as embedded content for 72 of 138 chunks, so those vectors encoded
|
| 16 |
+
markup. HTML is kept in `content_html` for display instead.
|
| 17 |
+
* Every chunk is kept. Enrichment is an overlay applied later, never a filter.
|
| 18 |
+
|
| 19 |
+
python backend/ingest/pipeline.py
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import re
|
| 27 |
+
import sys
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any, Dict, List, Optional
|
| 30 |
+
|
| 31 |
+
from unstructured.chunking.title import chunk_by_title
|
| 32 |
+
from unstructured.partition.html import partition_html
|
| 33 |
+
|
| 34 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 35 |
+
|
| 36 |
+
from backend.ingest.extract import ( # noqa: E402
|
| 37 |
+
DEFAULT_SOURCE,
|
| 38 |
+
ITEM_PATTERNS,
|
| 39 |
+
clean_html,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
OUTPUT_PATH = Path(__file__).resolve().parents[2] / "data" / "chunks_v2.json"
|
| 43 |
+
SOURCE_LABEL = "10-K/alphabet_10k_submission.txt"
|
| 44 |
+
|
| 45 |
+
MAX_CHARACTERS = 2048
|
| 46 |
+
COMBINE_UNDER = 256
|
| 47 |
+
NEW_AFTER = 1800
|
| 48 |
+
|
| 49 |
+
# `text_as_html` alone flags 114 of 200 chunks as tables, because iXBRL wraps
|
| 50 |
+
# narrative prose in <table> for page layout. The two populations separate
|
| 51 |
+
# cleanly when measured: layout wrappers have exactly 2 rows and a digit ratio
|
| 52 |
+
# near 0.001, while real financial tables have 19-46 rows and ratios of
|
| 53 |
+
# 0.15-0.21. Requiring both signals keeps the statements and drops the wrappers.
|
| 54 |
+
TABLE_MIN_ROWS = 4
|
| 55 |
+
TABLE_MIN_DIGIT_RATIO = 0.02
|
| 56 |
+
|
| 57 |
+
# A section heading is a short standalone line. Cross-references such as "see
|
| 58 |
+
# Item 1A Risk Factors for further detail" match the same patterns but sit inside
|
| 59 |
+
# long sentences, so length is what separates the two.
|
| 60 |
+
MAX_HEADING_CHARS = 120
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def digit_ratio(text: str) -> float:
|
| 64 |
+
if not text:
|
| 65 |
+
return 0.0
|
| 66 |
+
return sum(char.isdigit() for char in text) / len(text)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def is_real_table(html: Optional[str], text: str) -> bool:
|
| 70 |
+
if not html:
|
| 71 |
+
return False
|
| 72 |
+
rows = html.lower().count("<tr")
|
| 73 |
+
return rows >= TABLE_MIN_ROWS and digit_ratio(text) >= TABLE_MIN_DIGIT_RATIO
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def heading_section(text: str) -> Optional[str]:
|
| 77 |
+
"""The item this text is a heading for, or None if it is not a heading."""
|
| 78 |
+
stripped = " ".join(text.split())
|
| 79 |
+
if not stripped or len(stripped) > MAX_HEADING_CHARS:
|
| 80 |
+
return None
|
| 81 |
+
lowered = stripped.lower()
|
| 82 |
+
for key, pattern in ITEM_PATTERNS:
|
| 83 |
+
if re.match(pattern, lowered):
|
| 84 |
+
return key
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def tag_elements(elements) -> Dict[str, Optional[str]]:
|
| 89 |
+
"""Map element id -> 10-K section, by walking document order.
|
| 90 |
+
|
| 91 |
+
This replaces searching for each chunk's text inside the flat filing. That
|
| 92 |
+
search kept failing because unstructured collapses whitespace, so chunks
|
| 93 |
+
silently inherited the previous chunk's label and Item 1A never appeared at
|
| 94 |
+
all. Elements arrive in document order, so tracking the current heading as we
|
| 95 |
+
pass it needs no matching and cannot drift.
|
| 96 |
+
|
| 97 |
+
The table of contents lists every item before the body begins, which would
|
| 98 |
+
otherwise consume all ten headings immediately. It is skipped by requiring
|
| 99 |
+
that a heading not be immediately followed by another heading.
|
| 100 |
+
"""
|
| 101 |
+
labels: Dict[str, Optional[str]] = {}
|
| 102 |
+
current: Optional[str] = None
|
| 103 |
+
for index, element in enumerate(elements):
|
| 104 |
+
section = heading_section(element.text or "")
|
| 105 |
+
if section is not None:
|
| 106 |
+
following = elements[index + 1].text if index + 1 < len(elements) else ""
|
| 107 |
+
in_toc = heading_section(following or "") is not None
|
| 108 |
+
if not in_toc:
|
| 109 |
+
current = section
|
| 110 |
+
labels[str(element.id)] = current
|
| 111 |
+
return labels
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def build_chunks() -> List[Dict[str, Any]]:
|
| 115 |
+
print(f"Reading {DEFAULT_SOURCE.name}...")
|
| 116 |
+
cleaned = clean_html(DEFAULT_SOURCE, verbose=True)
|
| 117 |
+
print(f" cleaned html {len(cleaned):,} chars")
|
| 118 |
+
|
| 119 |
+
print("Partitioning...")
|
| 120 |
+
elements = partition_html(text=cleaned, infer_table_structure=True)
|
| 121 |
+
print(f" elements {len(elements):,}")
|
| 122 |
+
|
| 123 |
+
labels = tag_elements(elements)
|
| 124 |
+
tagged = sum(1 for value in labels.values() if value)
|
| 125 |
+
print(f" tagged {tagged:,}/{len(elements):,} elements with a section")
|
| 126 |
+
|
| 127 |
+
print("Chunking...")
|
| 128 |
+
chunks = chunk_by_title(
|
| 129 |
+
elements,
|
| 130 |
+
max_characters=MAX_CHARACTERS,
|
| 131 |
+
combine_text_under_n_chars=COMBINE_UNDER,
|
| 132 |
+
new_after_n_chars=NEW_AFTER,
|
| 133 |
+
include_orig_elements=True,
|
| 134 |
+
)
|
| 135 |
+
print(f" chunks {len(chunks):,}")
|
| 136 |
+
|
| 137 |
+
records: List[Dict[str, Any]] = []
|
| 138 |
+
fallback: Optional[str] = None
|
| 139 |
+
resolved = 0
|
| 140 |
+
for index, chunk in enumerate(chunks):
|
| 141 |
+
meta = chunk.metadata.to_dict()
|
| 142 |
+
html = meta.get("text_as_html")
|
| 143 |
+
text = (chunk.text or "").strip()
|
| 144 |
+
is_table = is_real_table(html, text)
|
| 145 |
+
|
| 146 |
+
if not text and not html:
|
| 147 |
+
continue
|
| 148 |
+
|
| 149 |
+
# Take the section from the chunk's constituent elements. Carry the last
|
| 150 |
+
# known section forward when a chunk has none, which mirrors how a reader
|
| 151 |
+
# would attribute unlabelled prose.
|
| 152 |
+
section: Optional[str] = None
|
| 153 |
+
for original in getattr(chunk.metadata, "orig_elements", None) or []:
|
| 154 |
+
section = labels.get(str(original.id))
|
| 155 |
+
if section:
|
| 156 |
+
break
|
| 157 |
+
if section:
|
| 158 |
+
resolved += 1
|
| 159 |
+
fallback = section
|
| 160 |
+
else:
|
| 161 |
+
section = fallback
|
| 162 |
+
|
| 163 |
+
records.append(
|
| 164 |
+
{
|
| 165 |
+
"id": index,
|
| 166 |
+
"source": SOURCE_LABEL,
|
| 167 |
+
"section": section,
|
| 168 |
+
"is_table": is_table,
|
| 169 |
+
# Embedded content is always text. HTML is display-only.
|
| 170 |
+
"content": text or "",
|
| 171 |
+
"content_html": html if is_table else None,
|
| 172 |
+
"enriched": False,
|
| 173 |
+
"summary": None,
|
| 174 |
+
"keywords": [],
|
| 175 |
+
"hypothetical_questions": [],
|
| 176 |
+
}
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
print(f" kept {len(records):,} non-empty chunks")
|
| 180 |
+
print(f" sectioned {resolved:,} chunks resolved directly from elements")
|
| 181 |
+
return records
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def report(records: List[Dict[str, Any]]) -> None:
|
| 185 |
+
tables = sum(1 for r in records if r["is_table"])
|
| 186 |
+
enriched = sum(1 for r in records if r["enriched"])
|
| 187 |
+
chars = sum(len(r["content"]) for r in records)
|
| 188 |
+
print("\nCorpus")
|
| 189 |
+
print(f" chunks {len(records):,} ({tables} tables, {len(records) - tables} prose)")
|
| 190 |
+
print(f" enriched {enriched:,}")
|
| 191 |
+
print(f" content chars {chars:,}")
|
| 192 |
+
|
| 193 |
+
by_section: Dict[str, int] = {}
|
| 194 |
+
for record in records:
|
| 195 |
+
key = record["section"] or "(unlabelled)"
|
| 196 |
+
by_section[key] = by_section.get(key, 0) + 1
|
| 197 |
+
print("\nChunks per section")
|
| 198 |
+
for name, count in sorted(by_section.items(), key=lambda kv: -kv[1]):
|
| 199 |
+
print(f" {name:<26} {count:>5}")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def main() -> int:
|
| 203 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 204 |
+
parser.add_argument("--out", type=Path, default=OUTPUT_PATH)
|
| 205 |
+
args = parser.parse_args()
|
| 206 |
+
|
| 207 |
+
records = build_chunks()
|
| 208 |
+
report(records)
|
| 209 |
+
|
| 210 |
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
| 211 |
+
args.out.write_text(json.dumps(records, indent=2), encoding="utf-8")
|
| 212 |
+
print(f"\nWrote {args.out}")
|
| 213 |
+
return 0
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
if __name__ == "__main__":
|
| 217 |
+
raise SystemExit(main())
|
backend/inspect_submission.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""What is actually inside data/alphabet_10k_submission.txt?
|
| 2 |
+
|
| 3 |
+
The ingestion scripts run partition_html over this whole file. It is an SEC
|
| 4 |
+
full-submission dissemination file: several SGML-wrapped documents including
|
| 5 |
+
exhibits and XBRL, not a single 10-K. This prints the document inventory so the
|
| 6 |
+
right one can be extracted.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
SOURCE = Path(__file__).resolve().parents[1] / "data" / "alphabet_10k_submission.txt"
|
| 15 |
+
|
| 16 |
+
DOC_RE = re.compile(r"<DOCUMENT>(.*?)</DOCUMENT>", re.S | re.I)
|
| 17 |
+
FIELD_RE = {
|
| 18 |
+
name: re.compile(rf"<{name}>(.*?)\s*$", re.I | re.M)
|
| 19 |
+
for name in ("TYPE", "SEQUENCE", "FILENAME", "DESCRIPTION")
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main() -> None:
|
| 24 |
+
raw = SOURCE.read_text(encoding="utf-8", errors="ignore")
|
| 25 |
+
print(f"file: {SOURCE.name} {len(raw):,} chars\n")
|
| 26 |
+
|
| 27 |
+
docs = DOC_RE.findall(raw)
|
| 28 |
+
if not docs:
|
| 29 |
+
print("No <DOCUMENT> wrappers found — file may be a bare HTML document.")
|
| 30 |
+
print(f"starts with: {raw[:300]!r}")
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
print(f"{len(docs)} documents inside\n")
|
| 34 |
+
print(f"{'#':>3} {'TYPE':<14} {'SEQ':>4} {'chars':>12} FILENAME")
|
| 35 |
+
for index, body in enumerate(docs, start=1):
|
| 36 |
+
fields = {}
|
| 37 |
+
for name, pattern in FIELD_RE.items():
|
| 38 |
+
match = pattern.search(body)
|
| 39 |
+
fields[name] = match.group(1).strip() if match else ""
|
| 40 |
+
print(
|
| 41 |
+
f"{index:>3} {fields['TYPE'][:14]:<14} {fields['SEQUENCE']:>4} "
|
| 42 |
+
f"{len(body):>12,} {fields['FILENAME'][:40]}"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Where does the risk-factor language actually live?
|
| 46 |
+
print("\nRisk-factor markers per document:")
|
| 47 |
+
for index, body in enumerate(docs, start=1):
|
| 48 |
+
lowered = body.lower()
|
| 49 |
+
counts = {
|
| 50 |
+
term: lowered.count(term)
|
| 51 |
+
for term in ("risk factors", "adversely affect", "antitrust", "competition")
|
| 52 |
+
}
|
| 53 |
+
if any(counts.values()):
|
| 54 |
+
type_match = FIELD_RE["TYPE"].search(body)
|
| 55 |
+
doc_type = type_match.group(1).strip() if type_match else "?"
|
| 56 |
+
print(f" doc {index} ({doc_type}): {counts}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == "__main__":
|
| 60 |
+
main()
|
backend/measure_corpus.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compare an indexed corpus against the filing's actual readable text.
|
| 2 |
+
|
| 3 |
+
python backend/measure_corpus.py # old corpus
|
| 4 |
+
python backend/measure_corpus.py --chunks data/chunks_v2.json
|
| 5 |
+
python backend/measure_corpus.py --chunks ... --gap # sample what is missing
|
| 6 |
+
|
| 7 |
+
An earlier version of this measurement divided indexed characters by raw file
|
| 8 |
+
size, which is ~86% XBRL markup, and reported 7.1% coverage. Visible text is the
|
| 9 |
+
honest denominator.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
import sys
|
| 18 |
+
from collections import Counter
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 22 |
+
|
| 23 |
+
from backend.ingest.extract import ( # noqa: E402
|
| 24 |
+
DEFAULT_SOURCE,
|
| 25 |
+
find_sections,
|
| 26 |
+
load_soup,
|
| 27 |
+
strip_hidden,
|
| 28 |
+
visible_text,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 32 |
+
TERMS = ["risk factors", "adversely affect", "antitrust", "competition", "could harm"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def normalise(text: str) -> str:
|
| 36 |
+
return " ".join(text.lower().split())
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> None:
|
| 40 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 41 |
+
parser.add_argument("--chunks", type=Path, default=PROJECT_ROOT / "data" / "enriched_chunks.json")
|
| 42 |
+
parser.add_argument("--gap", action="store_true", help="Sample lines absent from the corpus")
|
| 43 |
+
args = parser.parse_args()
|
| 44 |
+
|
| 45 |
+
print("Extracting visible text...")
|
| 46 |
+
text = visible_text(strip_hidden(load_soup()))
|
| 47 |
+
raw_size = DEFAULT_SOURCE.stat().st_size
|
| 48 |
+
|
| 49 |
+
print(f"\nraw file {raw_size:,} chars")
|
| 50 |
+
print(f"visible text {len(text):,} chars ({len(text) / raw_size * 100:.1f}% of raw)")
|
| 51 |
+
|
| 52 |
+
chunks = json.loads(args.chunks.read_text(encoding="utf-8"))
|
| 53 |
+
contents = [str(c.get("content") or "") for c in chunks]
|
| 54 |
+
indexed = " ".join(contents)
|
| 55 |
+
tables = sum(1 for c in chunks if c.get("is_table"))
|
| 56 |
+
|
| 57 |
+
print(f"\ncorpus {args.chunks.name}")
|
| 58 |
+
print(f" chunks {len(chunks):,} ({tables} tables, {len(chunks) - tables} prose)")
|
| 59 |
+
print(f" content chars {len(indexed):,}")
|
| 60 |
+
print(f" coverage {len(indexed) / len(text) * 100:.1f}% of visible text")
|
| 61 |
+
|
| 62 |
+
lowered_text = normalise(text)
|
| 63 |
+
lowered_indexed = normalise(indexed)
|
| 64 |
+
print(f"\n{'term':<20} {'corpus':>8} {'filing':>8} {'kept':>7}")
|
| 65 |
+
for term in TERMS:
|
| 66 |
+
found = lowered_indexed.count(term)
|
| 67 |
+
total = lowered_text.count(term)
|
| 68 |
+
pct = f"{found / total * 100:.0f}%" if total else "n/a"
|
| 69 |
+
print(f"{term:<20} {found:>8} {total:>8} {pct:>7}")
|
| 70 |
+
|
| 71 |
+
if not args.gap:
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
# Which sentences of the filing never made it into any chunk? Compare on
|
| 75 |
+
# normalised sentence text so whitespace and case differences do not count
|
| 76 |
+
# as losses.
|
| 77 |
+
print("\nGap analysis")
|
| 78 |
+
sentences = [s.strip() for s in re.split(r"\n+", text) if len(s.strip()) > 25]
|
| 79 |
+
missing = [s for s in sentences if normalise(s)[:60] not in lowered_indexed]
|
| 80 |
+
print(f" lines >25 chars {len(sentences):,}")
|
| 81 |
+
print(f" absent from corpus{len(missing):,} ({len(missing) / len(sentences) * 100:.1f}%)")
|
| 82 |
+
|
| 83 |
+
# Characterise the misses rather than eyeballing them one by one.
|
| 84 |
+
buckets: Counter[str] = Counter()
|
| 85 |
+
for line in missing:
|
| 86 |
+
stripped = line.strip()
|
| 87 |
+
if re.fullmatch(r"[\d\s.,$%()\-–—]+", stripped):
|
| 88 |
+
buckets["numeric / table fragment"] += 1
|
| 89 |
+
elif re.match(r"^(item|part)\s", stripped, re.I):
|
| 90 |
+
buckets["item or part heading"] += 1
|
| 91 |
+
elif len(stripped) < 60:
|
| 92 |
+
buckets["short label or cell"] += 1
|
| 93 |
+
elif stripped.count("$") > 2:
|
| 94 |
+
buckets["financial figures"] += 1
|
| 95 |
+
else:
|
| 96 |
+
buckets["prose"] += 1
|
| 97 |
+
print("\n composition of missing lines")
|
| 98 |
+
for name, count in buckets.most_common():
|
| 99 |
+
print(f" {name:<26} {count:>6}")
|
| 100 |
+
|
| 101 |
+
prose_missing = [
|
| 102 |
+
line
|
| 103 |
+
for line in missing
|
| 104 |
+
if len(line) >= 60
|
| 105 |
+
and line.count("$") <= 2
|
| 106 |
+
and not re.fullmatch(r"[\d\s.,$%()\-–—]+", line.strip())
|
| 107 |
+
and not re.match(r"^(item|part)\s", line.strip(), re.I)
|
| 108 |
+
]
|
| 109 |
+
print(f"\n sample missing prose ({len(prose_missing)} lines)")
|
| 110 |
+
for line in prose_missing[:12]:
|
| 111 |
+
print(f" - {' '.join(line.split())[:130]}")
|
| 112 |
+
|
| 113 |
+
sections = sorted(find_sections(text).items(), key=lambda kv: kv[1])
|
| 114 |
+
print("\n item headings located (offsets should ascend; they do not,")
|
| 115 |
+
print(" so section labels in the corpus are approximate)")
|
| 116 |
+
for name, start in sections:
|
| 117 |
+
print(f" {name:<26} {start:>9,}")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if __name__ == "__main__":
|
| 121 |
+
main()
|
backend/probe_denominator.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Is 'visible text' an honest denominator, and where does the lost prose live?
|
| 2 |
+
|
| 3 |
+
Two things to rule out before treating the 32% gap as real content loss:
|
| 4 |
+
|
| 5 |
+
1. That `strip_hidden` actually removes the iXBRL hidden fact store. If the tag
|
| 6 |
+
names do not match what the parser produced, the denominator silently
|
| 7 |
+
includes thousands of duplicated facts and every coverage number is wrong.
|
| 8 |
+
2. That the missing prose is not sitting inside <table> elements, where
|
| 9 |
+
`partition_html` would route it to `text_as_html` instead of `.text`.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from bs4 import BeautifulSoup
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 20 |
+
|
| 21 |
+
from backend.ingest.extract import DEFAULT_SOURCE, load_soup, strip_hidden, visible_text # noqa: E402
|
| 22 |
+
|
| 23 |
+
PROBE = "Antitrust Matters"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main() -> None:
|
| 27 |
+
soup = load_soup()
|
| 28 |
+
before = visible_text(soup)
|
| 29 |
+
print(f"visible text before strip_hidden {len(before):,}")
|
| 30 |
+
|
| 31 |
+
# What hidden-ish tags does the parser actually expose?
|
| 32 |
+
print("\ntag names matching hidden/header:")
|
| 33 |
+
for name in ("ix:header", "header", "ix:hidden", "hidden", "ix:references", "ix:resources"):
|
| 34 |
+
found = soup.find_all(name)
|
| 35 |
+
if found:
|
| 36 |
+
chars = sum(len(t.get_text()) for t in found)
|
| 37 |
+
print(f" {name:<16} {len(found):>4} tags, {chars:>10,} chars of text")
|
| 38 |
+
|
| 39 |
+
soup2 = strip_hidden(load_soup())
|
| 40 |
+
after = visible_text(soup2)
|
| 41 |
+
print(f"\nvisible text after strip_hidden {len(after):,}")
|
| 42 |
+
print(f"removed {len(before) - len(after):,}")
|
| 43 |
+
|
| 44 |
+
# Where does the lost passage live in the DOM?
|
| 45 |
+
raw = DEFAULT_SOURCE.read_text(encoding="utf-8", errors="ignore")
|
| 46 |
+
index = raw.find(PROBE)
|
| 47 |
+
print(f"\n'{PROBE}' found in raw at offset {index:,}")
|
| 48 |
+
if index < 0:
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
fresh = BeautifulSoup(raw, "lxml")
|
| 52 |
+
node = fresh.find(string=lambda s: s and PROBE in s)
|
| 53 |
+
if node is None:
|
| 54 |
+
print(" could not locate the string as a text node")
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
ancestors = [p.name for p in node.parents if p.name]
|
| 58 |
+
print(f" ancestor chain: {' < '.join(ancestors[:12])}")
|
| 59 |
+
print(f" inside a <table>? {'table' in ancestors}")
|
| 60 |
+
|
| 61 |
+
table = next((p for p in node.parents if p.name == "table"), None)
|
| 62 |
+
if table is not None:
|
| 63 |
+
text = " ".join(table.get_text(" ").split())
|
| 64 |
+
print(f" enclosing table holds {len(text):,} chars of text")
|
| 65 |
+
print(f" first 300: {text[:300]}")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
main()
|
backend/probe_partition.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Locate the stage that loses content: partitioning or chunking.
|
| 2 |
+
|
| 3 |
+
Chunking splits, it does not drop, so if element text already falls short of
|
| 4 |
+
visible text the loss is in `partition_html`. Also traces specific missing terms
|
| 5 |
+
back to their surrounding prose so the gap can be characterised rather than
|
| 6 |
+
guessed at.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from unstructured.chunking.title import chunk_by_title
|
| 16 |
+
from unstructured.partition.html import partition_html
|
| 17 |
+
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 19 |
+
|
| 20 |
+
from backend.ingest.extract import ( # noqa: E402
|
| 21 |
+
DEFAULT_SOURCE,
|
| 22 |
+
clean_html,
|
| 23 |
+
load_soup,
|
| 24 |
+
strip_hidden,
|
| 25 |
+
visible_text,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
TERM = "antitrust"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main() -> None:
|
| 32 |
+
plain = visible_text(strip_hidden(load_soup()))
|
| 33 |
+
cleaned = clean_html(DEFAULT_SOURCE)
|
| 34 |
+
|
| 35 |
+
elements = partition_html(text=cleaned, infer_table_structure=True)
|
| 36 |
+
element_chars = sum(len((el.text or "")) for el in elements)
|
| 37 |
+
|
| 38 |
+
chunks = chunk_by_title(
|
| 39 |
+
elements, max_characters=2048, combine_text_under_n_chars=256, new_after_n_chars=1800
|
| 40 |
+
)
|
| 41 |
+
chunk_chars = sum(len((c.text or "")) for c in chunks)
|
| 42 |
+
|
| 43 |
+
print("Stage-by-stage character counts")
|
| 44 |
+
print(f" visible text {len(plain):,}")
|
| 45 |
+
print(f" after partition {element_chars:,} ({element_chars / len(plain) * 100:.1f}%)")
|
| 46 |
+
print(f" after chunking {chunk_chars:,} ({chunk_chars / len(plain) * 100:.1f}%)")
|
| 47 |
+
|
| 48 |
+
by_type: dict[str, int] = {}
|
| 49 |
+
for el in elements:
|
| 50 |
+
name = type(el).__name__
|
| 51 |
+
by_type[name] = by_type.get(name, 0) + 1
|
| 52 |
+
print("\nElement types from partition")
|
| 53 |
+
for name, count in sorted(by_type.items(), key=lambda kv: -kv[1]):
|
| 54 |
+
print(f" {name:<24} {count:>5}")
|
| 55 |
+
|
| 56 |
+
# Trace the term through each stage.
|
| 57 |
+
element_text = " ".join((el.text or "") for el in elements).lower()
|
| 58 |
+
chunk_text = " ".join((c.text or "") for c in chunks).lower()
|
| 59 |
+
print(f"\n'{TERM}' occurrences")
|
| 60 |
+
print(f" visible text {plain.lower().count(TERM)}")
|
| 61 |
+
print(f" after partition {element_text.count(TERM)}")
|
| 62 |
+
print(f" after chunking {chunk_text.count(TERM)}")
|
| 63 |
+
|
| 64 |
+
print(f"\nContexts in visible text (first 8)")
|
| 65 |
+
for match in list(re.finditer(TERM, plain, re.I))[:8]:
|
| 66 |
+
start = max(0, match.start() - 90)
|
| 67 |
+
snippet = " ".join(plain[start : match.end() + 90].split())
|
| 68 |
+
present = "kept" if snippet[:50].lower() in element_text else "LOST"
|
| 69 |
+
print(f" [{present}] ...{snippet}...")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
main()
|
backend/probe_tables.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""How many chunks flagged is_table are genuinely tabular?
|
| 2 |
+
|
| 3 |
+
`text_as_html` presence is the current test, and it flags 114 of 200 chunks.
|
| 4 |
+
That is implausible for a 10-K: iXBRL uses <table> for page layout, so narrative
|
| 5 |
+
prose ends up wrapped in one. Digit density separates the two cleanly enough to
|
| 6 |
+
pick a threshold from the distribution rather than by guessing.
|
| 7 |
+
|
| 8 |
+
Reads the saved corpus rather than re-partitioning — partitioning this filing
|
| 9 |
+
segfaults intermittently, and the saved records already carry text and HTML.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
DEFAULT_CHUNKS = Path(__file__).resolve().parents[1] / "data" / "chunks_v2.json"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def digit_ratio(text: str) -> float:
|
| 22 |
+
if not text:
|
| 23 |
+
return 0.0
|
| 24 |
+
return sum(char.isdigit() for char in text) / len(text)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main() -> None:
|
| 28 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 29 |
+
parser.add_argument("--chunks", type=Path, default=DEFAULT_CHUNKS)
|
| 30 |
+
args = parser.parse_args()
|
| 31 |
+
|
| 32 |
+
rows = json.loads(args.chunks.read_text(encoding="utf-8"))
|
| 33 |
+
flagged = []
|
| 34 |
+
for record in rows:
|
| 35 |
+
if not record.get("is_table"):
|
| 36 |
+
continue
|
| 37 |
+
text = str(record.get("content") or "")
|
| 38 |
+
html = str(record.get("content_html") or "")
|
| 39 |
+
flagged.append(
|
| 40 |
+
{
|
| 41 |
+
"id": record["id"],
|
| 42 |
+
"section": record.get("section"),
|
| 43 |
+
"ratio": digit_ratio(text),
|
| 44 |
+
"rows": html.lower().count("<tr"),
|
| 45 |
+
"chars": len(text),
|
| 46 |
+
"head": " ".join(text.split())[:66],
|
| 47 |
+
}
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
print(f"{len(flagged)} of {len(rows)} chunks flagged is_table\n")
|
| 51 |
+
buckets = [(0.0, 0.02), (0.02, 0.05), (0.05, 0.10), (0.10, 0.20), (0.20, 1.01)]
|
| 52 |
+
for low, high in buckets:
|
| 53 |
+
group = [f for f in flagged if low <= f["ratio"] < high]
|
| 54 |
+
print(f" digit ratio {low:.2f}-{high:.2f} {len(group):>4} chunks")
|
| 55 |
+
|
| 56 |
+
print("\nlowest digit density (prose wrapped in a layout table)")
|
| 57 |
+
for item in sorted(flagged, key=lambda f: f["ratio"])[:8]:
|
| 58 |
+
print(f" id={item['id']:>3} r={item['ratio']:.3f} tr={item['rows']:>3} {item['head']}")
|
| 59 |
+
|
| 60 |
+
print("\nhighest digit density (real financial tables)")
|
| 61 |
+
for item in sorted(flagged, key=lambda f: -f["ratio"])[:8]:
|
| 62 |
+
print(f" id={item['id']:>3} r={item['ratio']:.3f} tr={item['rows']:>3} {item['head']}")
|
| 63 |
+
|
| 64 |
+
for threshold in (0.02, 0.03, 0.05):
|
| 65 |
+
kept = sum(1 for f in flagged if f["ratio"] >= threshold)
|
| 66 |
+
print(f"\nthreshold {threshold:.2f} -> {kept} tables, {len(rows) - kept} prose")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
main()
|
backend/reindex.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rebuild the Qdrant collection with the configured embedding provider.
|
| 2 |
+
|
| 3 |
+
python backend/reindex.py --dry-run # report what would change
|
| 4 |
+
python backend/reindex.py # rebuild
|
| 5 |
+
python backend/reindex.py --query "..." # rebuild then sanity-check retrieval
|
| 6 |
+
|
| 7 |
+
Why this exists: the original index holds 768-dim text-embedding-004 vectors, so
|
| 8 |
+
moving to a local model is not a config flip. Vectors from a different model are
|
| 9 |
+
not comparable to those, and Qdrant will not complain — it returns nearest
|
| 10 |
+
neighbours in the wrong space, so retrieval silently degrades to noise.
|
| 11 |
+
|
| 12 |
+
Replaces the Qdrant half of `populate_db.py`, which had an API key hardcoded at
|
| 13 |
+
line 27 and embedded via the Gemini batch endpoint.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
import sys
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any, Dict, List
|
| 23 |
+
|
| 24 |
+
from qdrant_client.models import Distance, PointStruct, VectorParams
|
| 25 |
+
|
| 26 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 27 |
+
|
| 28 |
+
from backend.app.agent.embeddings import get_embeddings, embedding_dimension # noqa: E402
|
| 29 |
+
from backend.app.agent.resources import get_qdrant # noqa: E402
|
| 30 |
+
from backend.app.config import PROJECT_ROOT, get_settings # noqa: E402
|
| 31 |
+
|
| 32 |
+
CHUNKS_PATH = PROJECT_ROOT / "data" / "chunks_v2.json"
|
| 33 |
+
BATCH_SIZE = 32
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def embedding_text(chunk: Dict[str, Any]) -> str:
|
| 37 |
+
"""Text actually sent to the embedding model.
|
| 38 |
+
|
| 39 |
+
Keeps `populate_db.py`'s summary + keywords + content recipe but adds the
|
| 40 |
+
hypothetical questions, which the enrichment step already generated and the
|
| 41 |
+
old pipeline then ignored. They tend to phrase the chunk the way a user
|
| 42 |
+
would ask for it, which is exactly what helps a query match.
|
| 43 |
+
"""
|
| 44 |
+
parts = []
|
| 45 |
+
if summary := chunk.get("summary"):
|
| 46 |
+
parts.append(f"Summary: {summary}")
|
| 47 |
+
if keywords := chunk.get("keywords"):
|
| 48 |
+
joined = ", ".join(keywords) if isinstance(keywords, list) else str(keywords)
|
| 49 |
+
parts.append(f"Keywords: {joined}")
|
| 50 |
+
if questions := chunk.get("hypothetical_questions"):
|
| 51 |
+
joined = " ".join(questions) if isinstance(questions, list) else str(questions)
|
| 52 |
+
parts.append(f"Answers questions like: {joined}")
|
| 53 |
+
if table_summary := chunk.get("table_summary"):
|
| 54 |
+
parts.append(f"Table: {table_summary}")
|
| 55 |
+
parts.append(f"Content: {str(chunk.get('content', ''))[:1500]}")
|
| 56 |
+
return "\n".join(parts)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def load_chunks() -> List[Dict[str, Any]]:
|
| 60 |
+
if not CHUNKS_PATH.exists():
|
| 61 |
+
raise SystemExit(f"No enriched chunks at {CHUNKS_PATH}. Run the enrichment step first.")
|
| 62 |
+
with CHUNKS_PATH.open(encoding="utf-8") as handle:
|
| 63 |
+
chunks = json.load(handle)
|
| 64 |
+
if not isinstance(chunks, list) or not chunks:
|
| 65 |
+
raise SystemExit(f"{CHUNKS_PATH} did not contain a non-empty list.")
|
| 66 |
+
return chunks
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def describe_existing(client, collection: str) -> str:
|
| 70 |
+
try:
|
| 71 |
+
info = client.get_collection(collection)
|
| 72 |
+
vectors = info.config.params.vectors
|
| 73 |
+
size = vectors.size if isinstance(vectors, VectorParams) else "?"
|
| 74 |
+
return f"{info.points_count} points, {size}-dim"
|
| 75 |
+
except Exception:
|
| 76 |
+
return "does not exist"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def reset_collection(client, dimension: int):
|
| 80 |
+
"""Drop and recreate the collection at `dimension`, returning a usable client.
|
| 81 |
+
|
| 82 |
+
In embedded (path) mode, delete_collection followed by create_collection does
|
| 83 |
+
not reliably resize the on-disk vector array — upserting then fails with
|
| 84 |
+
"could not broadcast input array from shape (384,) into shape (768,)". The
|
| 85 |
+
reliable reset there is to close the client and remove the storage directory.
|
| 86 |
+
Safe to do because the store is fully rebuildable from chunks_v2.json.
|
| 87 |
+
"""
|
| 88 |
+
settings = get_settings()
|
| 89 |
+
vectors_config = VectorParams(size=dimension, distance=Distance.COSINE)
|
| 90 |
+
|
| 91 |
+
if settings.qdrant_url:
|
| 92 |
+
if client.collection_exists(settings.collection_name):
|
| 93 |
+
client.delete_collection(settings.collection_name)
|
| 94 |
+
client.create_collection(settings.collection_name, vectors_config=vectors_config)
|
| 95 |
+
return client
|
| 96 |
+
|
| 97 |
+
import shutil
|
| 98 |
+
|
| 99 |
+
client.close()
|
| 100 |
+
get_qdrant.cache_clear()
|
| 101 |
+
if settings.qdrant_path.exists():
|
| 102 |
+
shutil.rmtree(settings.qdrant_path)
|
| 103 |
+
print(f" wiped {settings.qdrant_path}")
|
| 104 |
+
settings.qdrant_path.mkdir(parents=True, exist_ok=True)
|
| 105 |
+
|
| 106 |
+
fresh = get_qdrant()
|
| 107 |
+
fresh.create_collection(settings.collection_name, vectors_config=vectors_config)
|
| 108 |
+
|
| 109 |
+
actual = fresh.get_collection(settings.collection_name).config.params.vectors.size
|
| 110 |
+
if actual != dimension:
|
| 111 |
+
raise SystemExit(f"Collection created at {actual}-dim, expected {dimension}")
|
| 112 |
+
return fresh
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def main() -> int:
|
| 116 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 117 |
+
parser.add_argument("--dry-run", action="store_true", help="Report without writing")
|
| 118 |
+
parser.add_argument("--query", help="Run a retrieval check after rebuilding")
|
| 119 |
+
args = parser.parse_args()
|
| 120 |
+
|
| 121 |
+
settings = get_settings()
|
| 122 |
+
chunks = load_chunks()
|
| 123 |
+
client = get_qdrant()
|
| 124 |
+
|
| 125 |
+
print("Re-index plan")
|
| 126 |
+
print(f" chunks {len(chunks)} from {CHUNKS_PATH.name}")
|
| 127 |
+
print(f" provider {settings.embedding_provider}")
|
| 128 |
+
if settings.embedding_provider == "local":
|
| 129 |
+
print(f" model {settings.local_embedding_model}")
|
| 130 |
+
print(f" target {settings.qdrant_url or settings.qdrant_path}")
|
| 131 |
+
print(f" collection {settings.collection_name} ({describe_existing(client, settings.collection_name)})")
|
| 132 |
+
|
| 133 |
+
print("\nLoading embedding model...")
|
| 134 |
+
embedder = get_embeddings()
|
| 135 |
+
dimension = embedding_dimension()
|
| 136 |
+
print(f" new vectors {dimension}-dim")
|
| 137 |
+
|
| 138 |
+
if args.dry_run:
|
| 139 |
+
print("\nDry run: nothing written.")
|
| 140 |
+
return 0
|
| 141 |
+
|
| 142 |
+
print(f"\nRecreating collection '{settings.collection_name}'...")
|
| 143 |
+
client = reset_collection(client, dimension)
|
| 144 |
+
|
| 145 |
+
print(f"Embedding {len(chunks)} chunks...")
|
| 146 |
+
texts = [embedding_text(chunk) for chunk in chunks]
|
| 147 |
+
vectors: List[List[float]] = []
|
| 148 |
+
for start in range(0, len(texts), BATCH_SIZE):
|
| 149 |
+
batch = texts[start : start + BATCH_SIZE]
|
| 150 |
+
vectors.extend(embedder.embed_documents(batch))
|
| 151 |
+
print(f" {min(start + BATCH_SIZE, len(texts))}/{len(texts)}")
|
| 152 |
+
|
| 153 |
+
if len(vectors) != len(chunks):
|
| 154 |
+
raise SystemExit(f"Got {len(vectors)} vectors for {len(chunks)} chunks; aborting.")
|
| 155 |
+
|
| 156 |
+
points = [
|
| 157 |
+
PointStruct(
|
| 158 |
+
id=index,
|
| 159 |
+
vector=vector,
|
| 160 |
+
payload={
|
| 161 |
+
"source": chunk.get("source"),
|
| 162 |
+
"content": chunk.get("content"),
|
| 163 |
+
"summary": chunk.get("summary"),
|
| 164 |
+
"keywords": chunk.get("keywords"),
|
| 165 |
+
"is_table": chunk.get("is_table", False),
|
| 166 |
+
"section": chunk.get("section"),
|
| 167 |
+
"enriched": chunk.get("enriched", False),
|
| 168 |
+
# Written now so enabling per-user filtering later is a query
|
| 169 |
+
# change rather than another full re-index.
|
| 170 |
+
"tenant": settings.public_tenant,
|
| 171 |
+
},
|
| 172 |
+
)
|
| 173 |
+
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
|
| 174 |
+
]
|
| 175 |
+
|
| 176 |
+
print("Upserting...")
|
| 177 |
+
client.upsert(collection_name=settings.collection_name, points=points, wait=True)
|
| 178 |
+
count = client.get_collection(settings.collection_name).points_count
|
| 179 |
+
print(f"Done: {count} points, {dimension}-dim, cosine.")
|
| 180 |
+
|
| 181 |
+
if args.query:
|
| 182 |
+
print(f"\nRetrieval check: {args.query!r}")
|
| 183 |
+
hits = client.query_points(
|
| 184 |
+
collection_name=settings.collection_name,
|
| 185 |
+
query=embedder.embed_query(args.query),
|
| 186 |
+
limit=3,
|
| 187 |
+
with_payload=True,
|
| 188 |
+
).points
|
| 189 |
+
for rank, hit in enumerate(hits, start=1):
|
| 190 |
+
snippet = str(hit.payload.get("summary") or hit.payload.get("content"))
|
| 191 |
+
snippet = " ".join(snippet.split())[:150]
|
| 192 |
+
print(f" {rank}. score={hit.score:.4f} {snippet}")
|
| 193 |
+
|
| 194 |
+
return 0
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
if __name__ == "__main__":
|
| 198 |
+
raise SystemExit(main())
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API
|
| 2 |
+
fastapi>=0.115
|
| 3 |
+
uvicorn[standard]>=0.30
|
| 4 |
+
pydantic>=2.9
|
| 5 |
+
pydantic-settings>=2.5
|
| 6 |
+
|
| 7 |
+
# Agent
|
| 8 |
+
langchain-core>=0.3,<1.0
|
| 9 |
+
langchain-openai>=0.3
|
| 10 |
+
langgraph>=0.2
|
| 11 |
+
langchain-community>=0.3
|
| 12 |
+
duckduckgo-search>=6.0
|
| 13 |
+
|
| 14 |
+
# Retrieval
|
| 15 |
+
qdrant-client>=1.12
|
| 16 |
+
sentence-transformers>=3.0
|
| 17 |
+
pandas>=2.2
|
| 18 |
+
httpx>=0.27
|
backend/run_agent.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run the migrated agent against vLLM from the command line.
|
| 2 |
+
|
| 3 |
+
python backend/run_agent.py "What was total revenue in FY2024?"
|
| 4 |
+
python backend/run_agent.py --check # config + connectivity only
|
| 5 |
+
python backend/run_agent.py --nodes "..." # per-node timing
|
| 6 |
+
|
| 7 |
+
Exists so the migration can be verified without the HTTP layer in the way.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import asyncio
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 19 |
+
|
| 20 |
+
from backend.app.agent.graph import get_graph, run_config # noqa: E402
|
| 21 |
+
from backend.app.config import get_settings # noqa: E402
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def check() -> int:
|
| 25 |
+
settings = get_settings()
|
| 26 |
+
print("Configuration")
|
| 27 |
+
print(f" vLLM {settings.vllm_base_url}")
|
| 28 |
+
print(f" model {settings.vllm_model}")
|
| 29 |
+
print(f" embeddings {settings.embedding_provider}")
|
| 30 |
+
print(f" qdrant {settings.qdrant_url or settings.qdrant_path}")
|
| 31 |
+
print(f" collection {settings.collection_name}")
|
| 32 |
+
print(f" max_replans {settings.max_replans}")
|
| 33 |
+
print(f" recursion_limit {settings.recursion_limit}")
|
| 34 |
+
|
| 35 |
+
failures = 0
|
| 36 |
+
|
| 37 |
+
print("\nConnectivity")
|
| 38 |
+
try:
|
| 39 |
+
from backend.app.agent.llm import chat_model
|
| 40 |
+
|
| 41 |
+
t0 = time.perf_counter()
|
| 42 |
+
reply = await chat_model(max_tokens=8).ainvoke("Reply with: ok")
|
| 43 |
+
print(f" [OK] vLLM chat ({time.perf_counter() - t0:.2f}s) -> {reply.content!r}")
|
| 44 |
+
except Exception as exc:
|
| 45 |
+
print(f" [FAIL] vLLM chat: {type(exc).__name__}: {exc}")
|
| 46 |
+
failures += 1
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
from backend.app.agent.resources import collection_dimension
|
| 50 |
+
|
| 51 |
+
dim = collection_dimension()
|
| 52 |
+
print(f" [OK] Qdrant collection, {dim}-dim vectors")
|
| 53 |
+
except Exception as exc:
|
| 54 |
+
print(f" [FAIL] Qdrant: {type(exc).__name__}: {exc}")
|
| 55 |
+
failures += 1
|
| 56 |
+
dim = None
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
from backend.app.agent.embeddings import embedding_dimension
|
| 60 |
+
|
| 61 |
+
emb_dim = embedding_dimension()
|
| 62 |
+
if dim is not None and emb_dim != dim:
|
| 63 |
+
print(f" [FAIL] embeddings are {emb_dim}-dim but collection is {dim}-dim")
|
| 64 |
+
failures += 1
|
| 65 |
+
else:
|
| 66 |
+
print(f" [OK] embeddings, {emb_dim}-dim")
|
| 67 |
+
except Exception as exc:
|
| 68 |
+
print(f" [FAIL] embeddings: {type(exc).__name__}: {exc}")
|
| 69 |
+
failures += 1
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
from backend.app.agent.resources import get_sql_database
|
| 73 |
+
|
| 74 |
+
tables = get_sql_database().get_usable_table_names()
|
| 75 |
+
print(f" [OK] SQLite tables: {tables}")
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
print(f" [FAIL] SQLite: {type(exc).__name__}: {exc}")
|
| 78 |
+
failures += 1
|
| 79 |
+
|
| 80 |
+
print("\n" + ("all checks passed" if failures == 0 else f"{failures} check(s) failed"))
|
| 81 |
+
return 1 if failures else 0
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def stream_nodes(query: str) -> int:
|
| 85 |
+
"""Walk the graph, printing each node as it completes with its wall time."""
|
| 86 |
+
settings = get_settings()
|
| 87 |
+
print(f"Query: {query}\n")
|
| 88 |
+
|
| 89 |
+
state = {"original_request": query, "replan_count": 0}
|
| 90 |
+
started = time.perf_counter()
|
| 91 |
+
last = started
|
| 92 |
+
timings: dict[str, float] = {}
|
| 93 |
+
final = None
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
async for update in get_graph().astream(state, run_config()):
|
| 97 |
+
now = time.perf_counter()
|
| 98 |
+
for node, payload in update.items():
|
| 99 |
+
elapsed = now - last
|
| 100 |
+
timings[node] = timings.get(node, 0.0) + elapsed
|
| 101 |
+
print(f" {node:<14} {elapsed:>6.2f}s", end="")
|
| 102 |
+
|
| 103 |
+
if node == "planner" and payload.get("plan"):
|
| 104 |
+
names = [s["tool_name"] for s in payload["plan"]]
|
| 105 |
+
replans = payload.get("replan_count")
|
| 106 |
+
suffix = f" (replan #{replans})" if replans else ""
|
| 107 |
+
print(f" plan={names}{suffix}")
|
| 108 |
+
elif node == "execute_tool" and payload.get("intermediate_steps"):
|
| 109 |
+
step = payload["intermediate_steps"][-1]
|
| 110 |
+
preview = str(step["tool_output"]).replace("\n", " ")[:90]
|
| 111 |
+
print(f" {step['tool_name']} -> {preview}")
|
| 112 |
+
elif node == "verify" and payload.get("verification_history"):
|
| 113 |
+
audit = payload["verification_history"][-1]
|
| 114 |
+
verdict = (
|
| 115 |
+
"REPLAN"
|
| 116 |
+
if audit["confidence_score"] < settings.audit_pass_score
|
| 117 |
+
else "accept"
|
| 118 |
+
)
|
| 119 |
+
print(f" score={audit['confidence_score']}/5 {verdict}")
|
| 120 |
+
elif node == "gatekeeper":
|
| 121 |
+
q = payload.get("clarification_question")
|
| 122 |
+
print(f" {'CLARIFY: ' + q[:70] if q else 'specific'}")
|
| 123 |
+
else:
|
| 124 |
+
print()
|
| 125 |
+
|
| 126 |
+
if payload.get("final_response"):
|
| 127 |
+
final = payload["final_response"]
|
| 128 |
+
if payload.get("clarification_question"):
|
| 129 |
+
final = "[clarification] " + payload["clarification_question"]
|
| 130 |
+
last = now
|
| 131 |
+
except asyncio.TimeoutError:
|
| 132 |
+
print("\nAgent exceeded its wall-clock ceiling.")
|
| 133 |
+
return 1
|
| 134 |
+
|
| 135 |
+
total = time.perf_counter() - started
|
| 136 |
+
print(f"\n{'-' * 70}\n{final}\n{'-' * 70}")
|
| 137 |
+
print(f"total {total:.2f}s")
|
| 138 |
+
for node, seconds in sorted(timings.items(), key=lambda kv: -kv[1]):
|
| 139 |
+
print(f" {node:<14} {seconds:>6.2f}s {seconds / total * 100:>4.0f}%")
|
| 140 |
+
return 0
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def main() -> int:
|
| 144 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 145 |
+
parser.add_argument("query", nargs="?", help="Question to ask the agent")
|
| 146 |
+
parser.add_argument("--check", action="store_true", help="Config and connectivity only")
|
| 147 |
+
args = parser.parse_args()
|
| 148 |
+
|
| 149 |
+
if args.check or not args.query:
|
| 150 |
+
return asyncio.run(check())
|
| 151 |
+
return asyncio.run(stream_nodes(args.query))
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
raise SystemExit(main())
|
backend/smoke_live.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke-test the live SSE adapter against local vLLM. Prints event types only."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 11 |
+
|
| 12 |
+
from backend.app.stream import live_stream # noqa: E402
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def main() -> int:
|
| 16 |
+
query = "What was Alphabet total revenue in FY2024?"
|
| 17 |
+
print(f"query: {query}\n")
|
| 18 |
+
async for frame in live_stream(query):
|
| 19 |
+
if frame.startswith(":"):
|
| 20 |
+
print(" ping")
|
| 21 |
+
continue
|
| 22 |
+
for line in frame.splitlines():
|
| 23 |
+
if line.startswith("data: "):
|
| 24 |
+
payload = json.loads(line[6:])
|
| 25 |
+
kind = payload.get("type")
|
| 26 |
+
extra = ""
|
| 27 |
+
if kind == "node_start":
|
| 28 |
+
extra = f" {payload.get('node')}"
|
| 29 |
+
elif kind == "node_end":
|
| 30 |
+
extra = f" {payload.get('node')} {payload.get('duration_ms')}ms"
|
| 31 |
+
elif kind == "plan":
|
| 32 |
+
extra = " " + ",".join(s["tool_name"] for s in payload.get("steps", []))
|
| 33 |
+
elif kind == "tool_start":
|
| 34 |
+
extra = f" {payload.get('tool_name')}"
|
| 35 |
+
elif kind == "audit":
|
| 36 |
+
extra = f" {payload.get('confidence_score')}/5 {payload.get('action')}"
|
| 37 |
+
elif kind == "token":
|
| 38 |
+
extra = f" {payload.get('text')[:40]!r}"
|
| 39 |
+
elif kind == "error":
|
| 40 |
+
extra = f" {payload.get('code')}: {payload.get('message')}"
|
| 41 |
+
elif kind == "run_end":
|
| 42 |
+
extra = f" {payload.get('duration_ms')}ms replans={payload.get('replans')}"
|
| 43 |
+
print(f" {kind}{extra}")
|
| 44 |
+
return 0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
raise SystemExit(asyncio.run(main()))
|
data/alphabet_financials_structured.csv
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
year,quarter,revenue_usd_billions,net_income_usd_billions
|
| 2 |
+
2023,Q4,86.31,20.69
|
| 3 |
+
2023,Q3,76.69,19.69
|
| 4 |
+
2023,Q2,74.6,18.37
|
| 5 |
+
2023,Q1,69.79,15.05
|
| 6 |
+
2022,Q4,76.05,13.62
|
| 7 |
+
2022,Q3,69.09,13.91
|
| 8 |
+
2022,Q2,69.69,16.0
|
| 9 |
+
2022,Q1,68.01,16.44
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>FinRAG — Agentic Financial Analysis</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
@@ -0,0 +1,2363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "frontend",
|
| 9 |
+
"version": "0.0.0",
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"@tailwindcss/vite": "^4.1.11",
|
| 12 |
+
"react": "^19.1.0",
|
| 13 |
+
"react-dom": "^19.1.0",
|
| 14 |
+
"tailwindcss": "^4.1.11"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@types/node": "^22.15.0",
|
| 18 |
+
"@types/react": "^19.1.0",
|
| 19 |
+
"@types/react-dom": "^19.1.0",
|
| 20 |
+
"@vitejs/plugin-react": "^5.0.0",
|
| 21 |
+
"typescript": "~5.8.3",
|
| 22 |
+
"vite": "^7.1.0"
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"node_modules/@babel/code-frame": {
|
| 26 |
+
"version": "7.29.7",
|
| 27 |
+
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
| 28 |
+
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
| 29 |
+
"dev": true,
|
| 30 |
+
"license": "MIT",
|
| 31 |
+
"dependencies": {
|
| 32 |
+
"@babel/helper-validator-identifier": "^7.29.7",
|
| 33 |
+
"js-tokens": "^4.0.0",
|
| 34 |
+
"picocolors": "^1.1.1"
|
| 35 |
+
},
|
| 36 |
+
"engines": {
|
| 37 |
+
"node": ">=6.9.0"
|
| 38 |
+
}
|
| 39 |
+
},
|
| 40 |
+
"node_modules/@babel/compat-data": {
|
| 41 |
+
"version": "7.29.7",
|
| 42 |
+
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
| 43 |
+
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
| 44 |
+
"dev": true,
|
| 45 |
+
"license": "MIT",
|
| 46 |
+
"engines": {
|
| 47 |
+
"node": ">=6.9.0"
|
| 48 |
+
}
|
| 49 |
+
},
|
| 50 |
+
"node_modules/@babel/core": {
|
| 51 |
+
"version": "7.29.7",
|
| 52 |
+
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
| 53 |
+
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
| 54 |
+
"dev": true,
|
| 55 |
+
"license": "MIT",
|
| 56 |
+
"dependencies": {
|
| 57 |
+
"@babel/code-frame": "^7.29.7",
|
| 58 |
+
"@babel/generator": "^7.29.7",
|
| 59 |
+
"@babel/helper-compilation-targets": "^7.29.7",
|
| 60 |
+
"@babel/helper-module-transforms": "^7.29.7",
|
| 61 |
+
"@babel/helpers": "^7.29.7",
|
| 62 |
+
"@babel/parser": "^7.29.7",
|
| 63 |
+
"@babel/template": "^7.29.7",
|
| 64 |
+
"@babel/traverse": "^7.29.7",
|
| 65 |
+
"@babel/types": "^7.29.7",
|
| 66 |
+
"@jridgewell/remapping": "^2.3.5",
|
| 67 |
+
"convert-source-map": "^2.0.0",
|
| 68 |
+
"debug": "^4.1.0",
|
| 69 |
+
"gensync": "^1.0.0-beta.2",
|
| 70 |
+
"json5": "^2.2.3",
|
| 71 |
+
"semver": "^6.3.1"
|
| 72 |
+
},
|
| 73 |
+
"engines": {
|
| 74 |
+
"node": ">=6.9.0"
|
| 75 |
+
},
|
| 76 |
+
"funding": {
|
| 77 |
+
"type": "opencollective",
|
| 78 |
+
"url": "https://opencollective.com/babel"
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
"node_modules/@babel/generator": {
|
| 82 |
+
"version": "7.29.8",
|
| 83 |
+
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
|
| 84 |
+
"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
|
| 85 |
+
"dev": true,
|
| 86 |
+
"license": "MIT",
|
| 87 |
+
"dependencies": {
|
| 88 |
+
"@babel/parser": "^7.29.8",
|
| 89 |
+
"@babel/types": "^7.29.8",
|
| 90 |
+
"@jridgewell/gen-mapping": "^0.3.12",
|
| 91 |
+
"@jridgewell/trace-mapping": "^0.3.28",
|
| 92 |
+
"jsesc": "^3.0.2"
|
| 93 |
+
},
|
| 94 |
+
"engines": {
|
| 95 |
+
"node": ">=6.9.0"
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
"node_modules/@babel/helper-compilation-targets": {
|
| 99 |
+
"version": "7.29.7",
|
| 100 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
| 101 |
+
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
| 102 |
+
"dev": true,
|
| 103 |
+
"license": "MIT",
|
| 104 |
+
"dependencies": {
|
| 105 |
+
"@babel/compat-data": "^7.29.7",
|
| 106 |
+
"@babel/helper-validator-option": "^7.29.7",
|
| 107 |
+
"browserslist": "^4.24.0",
|
| 108 |
+
"lru-cache": "^5.1.1",
|
| 109 |
+
"semver": "^6.3.1"
|
| 110 |
+
},
|
| 111 |
+
"engines": {
|
| 112 |
+
"node": ">=6.9.0"
|
| 113 |
+
}
|
| 114 |
+
},
|
| 115 |
+
"node_modules/@babel/helper-globals": {
|
| 116 |
+
"version": "7.29.7",
|
| 117 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
| 118 |
+
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
| 119 |
+
"dev": true,
|
| 120 |
+
"license": "MIT",
|
| 121 |
+
"engines": {
|
| 122 |
+
"node": ">=6.9.0"
|
| 123 |
+
}
|
| 124 |
+
},
|
| 125 |
+
"node_modules/@babel/helper-module-imports": {
|
| 126 |
+
"version": "7.29.7",
|
| 127 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
| 128 |
+
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
| 129 |
+
"dev": true,
|
| 130 |
+
"license": "MIT",
|
| 131 |
+
"dependencies": {
|
| 132 |
+
"@babel/traverse": "^7.29.7",
|
| 133 |
+
"@babel/types": "^7.29.7"
|
| 134 |
+
},
|
| 135 |
+
"engines": {
|
| 136 |
+
"node": ">=6.9.0"
|
| 137 |
+
}
|
| 138 |
+
},
|
| 139 |
+
"node_modules/@babel/helper-module-transforms": {
|
| 140 |
+
"version": "7.29.7",
|
| 141 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
| 142 |
+
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
| 143 |
+
"dev": true,
|
| 144 |
+
"license": "MIT",
|
| 145 |
+
"dependencies": {
|
| 146 |
+
"@babel/helper-module-imports": "^7.29.7",
|
| 147 |
+
"@babel/helper-validator-identifier": "^7.29.7",
|
| 148 |
+
"@babel/traverse": "^7.29.7"
|
| 149 |
+
},
|
| 150 |
+
"engines": {
|
| 151 |
+
"node": ">=6.9.0"
|
| 152 |
+
},
|
| 153 |
+
"peerDependencies": {
|
| 154 |
+
"@babel/core": "^7.0.0"
|
| 155 |
+
}
|
| 156 |
+
},
|
| 157 |
+
"node_modules/@babel/helper-plugin-utils": {
|
| 158 |
+
"version": "7.29.7",
|
| 159 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
|
| 160 |
+
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
|
| 161 |
+
"dev": true,
|
| 162 |
+
"license": "MIT",
|
| 163 |
+
"engines": {
|
| 164 |
+
"node": ">=6.9.0"
|
| 165 |
+
}
|
| 166 |
+
},
|
| 167 |
+
"node_modules/@babel/helper-string-parser": {
|
| 168 |
+
"version": "7.29.7",
|
| 169 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
| 170 |
+
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
| 171 |
+
"dev": true,
|
| 172 |
+
"license": "MIT",
|
| 173 |
+
"engines": {
|
| 174 |
+
"node": ">=6.9.0"
|
| 175 |
+
}
|
| 176 |
+
},
|
| 177 |
+
"node_modules/@babel/helper-validator-identifier": {
|
| 178 |
+
"version": "7.29.7",
|
| 179 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
| 180 |
+
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
| 181 |
+
"dev": true,
|
| 182 |
+
"license": "MIT",
|
| 183 |
+
"engines": {
|
| 184 |
+
"node": ">=6.9.0"
|
| 185 |
+
}
|
| 186 |
+
},
|
| 187 |
+
"node_modules/@babel/helper-validator-option": {
|
| 188 |
+
"version": "7.29.7",
|
| 189 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
| 190 |
+
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
| 191 |
+
"dev": true,
|
| 192 |
+
"license": "MIT",
|
| 193 |
+
"engines": {
|
| 194 |
+
"node": ">=6.9.0"
|
| 195 |
+
}
|
| 196 |
+
},
|
| 197 |
+
"node_modules/@babel/helpers": {
|
| 198 |
+
"version": "7.29.7",
|
| 199 |
+
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
| 200 |
+
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
| 201 |
+
"dev": true,
|
| 202 |
+
"license": "MIT",
|
| 203 |
+
"dependencies": {
|
| 204 |
+
"@babel/template": "^7.29.7",
|
| 205 |
+
"@babel/types": "^7.29.7"
|
| 206 |
+
},
|
| 207 |
+
"engines": {
|
| 208 |
+
"node": ">=6.9.0"
|
| 209 |
+
}
|
| 210 |
+
},
|
| 211 |
+
"node_modules/@babel/parser": {
|
| 212 |
+
"version": "7.29.8",
|
| 213 |
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
| 214 |
+
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
| 215 |
+
"dev": true,
|
| 216 |
+
"license": "MIT",
|
| 217 |
+
"dependencies": {
|
| 218 |
+
"@babel/types": "^7.29.8"
|
| 219 |
+
},
|
| 220 |
+
"bin": {
|
| 221 |
+
"parser": "bin/babel-parser.js"
|
| 222 |
+
},
|
| 223 |
+
"engines": {
|
| 224 |
+
"node": ">=6.0.0"
|
| 225 |
+
}
|
| 226 |
+
},
|
| 227 |
+
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
| 228 |
+
"version": "7.29.7",
|
| 229 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
|
| 230 |
+
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
|
| 231 |
+
"dev": true,
|
| 232 |
+
"license": "MIT",
|
| 233 |
+
"dependencies": {
|
| 234 |
+
"@babel/helper-plugin-utils": "^7.29.7"
|
| 235 |
+
},
|
| 236 |
+
"engines": {
|
| 237 |
+
"node": ">=6.9.0"
|
| 238 |
+
},
|
| 239 |
+
"peerDependencies": {
|
| 240 |
+
"@babel/core": "^7.0.0-0"
|
| 241 |
+
}
|
| 242 |
+
},
|
| 243 |
+
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
| 244 |
+
"version": "7.29.7",
|
| 245 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
|
| 246 |
+
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
|
| 247 |
+
"dev": true,
|
| 248 |
+
"license": "MIT",
|
| 249 |
+
"dependencies": {
|
| 250 |
+
"@babel/helper-plugin-utils": "^7.29.7"
|
| 251 |
+
},
|
| 252 |
+
"engines": {
|
| 253 |
+
"node": ">=6.9.0"
|
| 254 |
+
},
|
| 255 |
+
"peerDependencies": {
|
| 256 |
+
"@babel/core": "^7.0.0-0"
|
| 257 |
+
}
|
| 258 |
+
},
|
| 259 |
+
"node_modules/@babel/template": {
|
| 260 |
+
"version": "7.29.7",
|
| 261 |
+
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
| 262 |
+
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
| 263 |
+
"dev": true,
|
| 264 |
+
"license": "MIT",
|
| 265 |
+
"dependencies": {
|
| 266 |
+
"@babel/code-frame": "^7.29.7",
|
| 267 |
+
"@babel/parser": "^7.29.7",
|
| 268 |
+
"@babel/types": "^7.29.7"
|
| 269 |
+
},
|
| 270 |
+
"engines": {
|
| 271 |
+
"node": ">=6.9.0"
|
| 272 |
+
}
|
| 273 |
+
},
|
| 274 |
+
"node_modules/@babel/traverse": {
|
| 275 |
+
"version": "7.29.8",
|
| 276 |
+
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
|
| 277 |
+
"integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
|
| 278 |
+
"dev": true,
|
| 279 |
+
"license": "MIT",
|
| 280 |
+
"dependencies": {
|
| 281 |
+
"@babel/code-frame": "^7.29.7",
|
| 282 |
+
"@babel/generator": "^7.29.8",
|
| 283 |
+
"@babel/helper-globals": "^7.29.7",
|
| 284 |
+
"@babel/parser": "^7.29.8",
|
| 285 |
+
"@babel/template": "^7.29.7",
|
| 286 |
+
"@babel/types": "^7.29.8",
|
| 287 |
+
"debug": "^4.3.1"
|
| 288 |
+
},
|
| 289 |
+
"engines": {
|
| 290 |
+
"node": ">=6.9.0"
|
| 291 |
+
}
|
| 292 |
+
},
|
| 293 |
+
"node_modules/@babel/types": {
|
| 294 |
+
"version": "7.29.8",
|
| 295 |
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
| 296 |
+
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
| 297 |
+
"dev": true,
|
| 298 |
+
"license": "MIT",
|
| 299 |
+
"dependencies": {
|
| 300 |
+
"@babel/helper-string-parser": "^7.29.7",
|
| 301 |
+
"@babel/helper-validator-identifier": "^7.29.7"
|
| 302 |
+
},
|
| 303 |
+
"engines": {
|
| 304 |
+
"node": ">=6.9.0"
|
| 305 |
+
}
|
| 306 |
+
},
|
| 307 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 308 |
+
"version": "0.28.2",
|
| 309 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
| 310 |
+
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
| 311 |
+
"cpu": [
|
| 312 |
+
"ppc64"
|
| 313 |
+
],
|
| 314 |
+
"license": "MIT",
|
| 315 |
+
"optional": true,
|
| 316 |
+
"os": [
|
| 317 |
+
"aix"
|
| 318 |
+
],
|
| 319 |
+
"engines": {
|
| 320 |
+
"node": ">=18"
|
| 321 |
+
}
|
| 322 |
+
},
|
| 323 |
+
"node_modules/@esbuild/android-arm": {
|
| 324 |
+
"version": "0.28.2",
|
| 325 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
| 326 |
+
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
| 327 |
+
"cpu": [
|
| 328 |
+
"arm"
|
| 329 |
+
],
|
| 330 |
+
"license": "MIT",
|
| 331 |
+
"optional": true,
|
| 332 |
+
"os": [
|
| 333 |
+
"android"
|
| 334 |
+
],
|
| 335 |
+
"engines": {
|
| 336 |
+
"node": ">=18"
|
| 337 |
+
}
|
| 338 |
+
},
|
| 339 |
+
"node_modules/@esbuild/android-arm64": {
|
| 340 |
+
"version": "0.28.2",
|
| 341 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
| 342 |
+
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
| 343 |
+
"cpu": [
|
| 344 |
+
"arm64"
|
| 345 |
+
],
|
| 346 |
+
"license": "MIT",
|
| 347 |
+
"optional": true,
|
| 348 |
+
"os": [
|
| 349 |
+
"android"
|
| 350 |
+
],
|
| 351 |
+
"engines": {
|
| 352 |
+
"node": ">=18"
|
| 353 |
+
}
|
| 354 |
+
},
|
| 355 |
+
"node_modules/@esbuild/android-x64": {
|
| 356 |
+
"version": "0.28.2",
|
| 357 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
| 358 |
+
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
| 359 |
+
"cpu": [
|
| 360 |
+
"x64"
|
| 361 |
+
],
|
| 362 |
+
"license": "MIT",
|
| 363 |
+
"optional": true,
|
| 364 |
+
"os": [
|
| 365 |
+
"android"
|
| 366 |
+
],
|
| 367 |
+
"engines": {
|
| 368 |
+
"node": ">=18"
|
| 369 |
+
}
|
| 370 |
+
},
|
| 371 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 372 |
+
"version": "0.28.2",
|
| 373 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
| 374 |
+
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
| 375 |
+
"cpu": [
|
| 376 |
+
"arm64"
|
| 377 |
+
],
|
| 378 |
+
"license": "MIT",
|
| 379 |
+
"optional": true,
|
| 380 |
+
"os": [
|
| 381 |
+
"darwin"
|
| 382 |
+
],
|
| 383 |
+
"engines": {
|
| 384 |
+
"node": ">=18"
|
| 385 |
+
}
|
| 386 |
+
},
|
| 387 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 388 |
+
"version": "0.28.2",
|
| 389 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
| 390 |
+
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
| 391 |
+
"cpu": [
|
| 392 |
+
"x64"
|
| 393 |
+
],
|
| 394 |
+
"license": "MIT",
|
| 395 |
+
"optional": true,
|
| 396 |
+
"os": [
|
| 397 |
+
"darwin"
|
| 398 |
+
],
|
| 399 |
+
"engines": {
|
| 400 |
+
"node": ">=18"
|
| 401 |
+
}
|
| 402 |
+
},
|
| 403 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 404 |
+
"version": "0.28.2",
|
| 405 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
| 406 |
+
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
| 407 |
+
"cpu": [
|
| 408 |
+
"arm64"
|
| 409 |
+
],
|
| 410 |
+
"license": "MIT",
|
| 411 |
+
"optional": true,
|
| 412 |
+
"os": [
|
| 413 |
+
"freebsd"
|
| 414 |
+
],
|
| 415 |
+
"engines": {
|
| 416 |
+
"node": ">=18"
|
| 417 |
+
}
|
| 418 |
+
},
|
| 419 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 420 |
+
"version": "0.28.2",
|
| 421 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
| 422 |
+
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
| 423 |
+
"cpu": [
|
| 424 |
+
"x64"
|
| 425 |
+
],
|
| 426 |
+
"license": "MIT",
|
| 427 |
+
"optional": true,
|
| 428 |
+
"os": [
|
| 429 |
+
"freebsd"
|
| 430 |
+
],
|
| 431 |
+
"engines": {
|
| 432 |
+
"node": ">=18"
|
| 433 |
+
}
|
| 434 |
+
},
|
| 435 |
+
"node_modules/@esbuild/linux-arm": {
|
| 436 |
+
"version": "0.28.2",
|
| 437 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
| 438 |
+
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
| 439 |
+
"cpu": [
|
| 440 |
+
"arm"
|
| 441 |
+
],
|
| 442 |
+
"license": "MIT",
|
| 443 |
+
"optional": true,
|
| 444 |
+
"os": [
|
| 445 |
+
"linux"
|
| 446 |
+
],
|
| 447 |
+
"engines": {
|
| 448 |
+
"node": ">=18"
|
| 449 |
+
}
|
| 450 |
+
},
|
| 451 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 452 |
+
"version": "0.28.2",
|
| 453 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
| 454 |
+
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
| 455 |
+
"cpu": [
|
| 456 |
+
"arm64"
|
| 457 |
+
],
|
| 458 |
+
"license": "MIT",
|
| 459 |
+
"optional": true,
|
| 460 |
+
"os": [
|
| 461 |
+
"linux"
|
| 462 |
+
],
|
| 463 |
+
"engines": {
|
| 464 |
+
"node": ">=18"
|
| 465 |
+
}
|
| 466 |
+
},
|
| 467 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 468 |
+
"version": "0.28.2",
|
| 469 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
| 470 |
+
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
| 471 |
+
"cpu": [
|
| 472 |
+
"ia32"
|
| 473 |
+
],
|
| 474 |
+
"license": "MIT",
|
| 475 |
+
"optional": true,
|
| 476 |
+
"os": [
|
| 477 |
+
"linux"
|
| 478 |
+
],
|
| 479 |
+
"engines": {
|
| 480 |
+
"node": ">=18"
|
| 481 |
+
}
|
| 482 |
+
},
|
| 483 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 484 |
+
"version": "0.28.2",
|
| 485 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
| 486 |
+
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
| 487 |
+
"cpu": [
|
| 488 |
+
"loong64"
|
| 489 |
+
],
|
| 490 |
+
"license": "MIT",
|
| 491 |
+
"optional": true,
|
| 492 |
+
"os": [
|
| 493 |
+
"linux"
|
| 494 |
+
],
|
| 495 |
+
"engines": {
|
| 496 |
+
"node": ">=18"
|
| 497 |
+
}
|
| 498 |
+
},
|
| 499 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 500 |
+
"version": "0.28.2",
|
| 501 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
| 502 |
+
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
| 503 |
+
"cpu": [
|
| 504 |
+
"mips64el"
|
| 505 |
+
],
|
| 506 |
+
"license": "MIT",
|
| 507 |
+
"optional": true,
|
| 508 |
+
"os": [
|
| 509 |
+
"linux"
|
| 510 |
+
],
|
| 511 |
+
"engines": {
|
| 512 |
+
"node": ">=18"
|
| 513 |
+
}
|
| 514 |
+
},
|
| 515 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 516 |
+
"version": "0.28.2",
|
| 517 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
| 518 |
+
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
| 519 |
+
"cpu": [
|
| 520 |
+
"ppc64"
|
| 521 |
+
],
|
| 522 |
+
"license": "MIT",
|
| 523 |
+
"optional": true,
|
| 524 |
+
"os": [
|
| 525 |
+
"linux"
|
| 526 |
+
],
|
| 527 |
+
"engines": {
|
| 528 |
+
"node": ">=18"
|
| 529 |
+
}
|
| 530 |
+
},
|
| 531 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 532 |
+
"version": "0.28.2",
|
| 533 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
| 534 |
+
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
| 535 |
+
"cpu": [
|
| 536 |
+
"riscv64"
|
| 537 |
+
],
|
| 538 |
+
"license": "MIT",
|
| 539 |
+
"optional": true,
|
| 540 |
+
"os": [
|
| 541 |
+
"linux"
|
| 542 |
+
],
|
| 543 |
+
"engines": {
|
| 544 |
+
"node": ">=18"
|
| 545 |
+
}
|
| 546 |
+
},
|
| 547 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 548 |
+
"version": "0.28.2",
|
| 549 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
| 550 |
+
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
| 551 |
+
"cpu": [
|
| 552 |
+
"s390x"
|
| 553 |
+
],
|
| 554 |
+
"license": "MIT",
|
| 555 |
+
"optional": true,
|
| 556 |
+
"os": [
|
| 557 |
+
"linux"
|
| 558 |
+
],
|
| 559 |
+
"engines": {
|
| 560 |
+
"node": ">=18"
|
| 561 |
+
}
|
| 562 |
+
},
|
| 563 |
+
"node_modules/@esbuild/linux-x64": {
|
| 564 |
+
"version": "0.28.2",
|
| 565 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
| 566 |
+
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
| 567 |
+
"cpu": [
|
| 568 |
+
"x64"
|
| 569 |
+
],
|
| 570 |
+
"license": "MIT",
|
| 571 |
+
"optional": true,
|
| 572 |
+
"os": [
|
| 573 |
+
"linux"
|
| 574 |
+
],
|
| 575 |
+
"engines": {
|
| 576 |
+
"node": ">=18"
|
| 577 |
+
}
|
| 578 |
+
},
|
| 579 |
+
"node_modules/@esbuild/netbsd-arm64": {
|
| 580 |
+
"version": "0.28.2",
|
| 581 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
| 582 |
+
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
| 583 |
+
"cpu": [
|
| 584 |
+
"arm64"
|
| 585 |
+
],
|
| 586 |
+
"license": "MIT",
|
| 587 |
+
"optional": true,
|
| 588 |
+
"os": [
|
| 589 |
+
"netbsd"
|
| 590 |
+
],
|
| 591 |
+
"engines": {
|
| 592 |
+
"node": ">=18"
|
| 593 |
+
}
|
| 594 |
+
},
|
| 595 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 596 |
+
"version": "0.28.2",
|
| 597 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
| 598 |
+
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
| 599 |
+
"cpu": [
|
| 600 |
+
"x64"
|
| 601 |
+
],
|
| 602 |
+
"license": "MIT",
|
| 603 |
+
"optional": true,
|
| 604 |
+
"os": [
|
| 605 |
+
"netbsd"
|
| 606 |
+
],
|
| 607 |
+
"engines": {
|
| 608 |
+
"node": ">=18"
|
| 609 |
+
}
|
| 610 |
+
},
|
| 611 |
+
"node_modules/@esbuild/openbsd-arm64": {
|
| 612 |
+
"version": "0.28.2",
|
| 613 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
| 614 |
+
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
| 615 |
+
"cpu": [
|
| 616 |
+
"arm64"
|
| 617 |
+
],
|
| 618 |
+
"license": "MIT",
|
| 619 |
+
"optional": true,
|
| 620 |
+
"os": [
|
| 621 |
+
"openbsd"
|
| 622 |
+
],
|
| 623 |
+
"engines": {
|
| 624 |
+
"node": ">=18"
|
| 625 |
+
}
|
| 626 |
+
},
|
| 627 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 628 |
+
"version": "0.28.2",
|
| 629 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
| 630 |
+
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
| 631 |
+
"cpu": [
|
| 632 |
+
"x64"
|
| 633 |
+
],
|
| 634 |
+
"license": "MIT",
|
| 635 |
+
"optional": true,
|
| 636 |
+
"os": [
|
| 637 |
+
"openbsd"
|
| 638 |
+
],
|
| 639 |
+
"engines": {
|
| 640 |
+
"node": ">=18"
|
| 641 |
+
}
|
| 642 |
+
},
|
| 643 |
+
"node_modules/@esbuild/openharmony-arm64": {
|
| 644 |
+
"version": "0.28.2",
|
| 645 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
| 646 |
+
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
| 647 |
+
"cpu": [
|
| 648 |
+
"arm64"
|
| 649 |
+
],
|
| 650 |
+
"license": "MIT",
|
| 651 |
+
"optional": true,
|
| 652 |
+
"os": [
|
| 653 |
+
"openharmony"
|
| 654 |
+
],
|
| 655 |
+
"engines": {
|
| 656 |
+
"node": ">=18"
|
| 657 |
+
}
|
| 658 |
+
},
|
| 659 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 660 |
+
"version": "0.28.2",
|
| 661 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
| 662 |
+
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
| 663 |
+
"cpu": [
|
| 664 |
+
"x64"
|
| 665 |
+
],
|
| 666 |
+
"license": "MIT",
|
| 667 |
+
"optional": true,
|
| 668 |
+
"os": [
|
| 669 |
+
"sunos"
|
| 670 |
+
],
|
| 671 |
+
"engines": {
|
| 672 |
+
"node": ">=18"
|
| 673 |
+
}
|
| 674 |
+
},
|
| 675 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 676 |
+
"version": "0.28.2",
|
| 677 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
| 678 |
+
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
| 679 |
+
"cpu": [
|
| 680 |
+
"arm64"
|
| 681 |
+
],
|
| 682 |
+
"license": "MIT",
|
| 683 |
+
"optional": true,
|
| 684 |
+
"os": [
|
| 685 |
+
"win32"
|
| 686 |
+
],
|
| 687 |
+
"engines": {
|
| 688 |
+
"node": ">=18"
|
| 689 |
+
}
|
| 690 |
+
},
|
| 691 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 692 |
+
"version": "0.28.2",
|
| 693 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
| 694 |
+
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
| 695 |
+
"cpu": [
|
| 696 |
+
"ia32"
|
| 697 |
+
],
|
| 698 |
+
"license": "MIT",
|
| 699 |
+
"optional": true,
|
| 700 |
+
"os": [
|
| 701 |
+
"win32"
|
| 702 |
+
],
|
| 703 |
+
"engines": {
|
| 704 |
+
"node": ">=18"
|
| 705 |
+
}
|
| 706 |
+
},
|
| 707 |
+
"node_modules/@esbuild/win32-x64": {
|
| 708 |
+
"version": "0.28.2",
|
| 709 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
| 710 |
+
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
| 711 |
+
"cpu": [
|
| 712 |
+
"x64"
|
| 713 |
+
],
|
| 714 |
+
"license": "MIT",
|
| 715 |
+
"optional": true,
|
| 716 |
+
"os": [
|
| 717 |
+
"win32"
|
| 718 |
+
],
|
| 719 |
+
"engines": {
|
| 720 |
+
"node": ">=18"
|
| 721 |
+
}
|
| 722 |
+
},
|
| 723 |
+
"node_modules/@jridgewell/gen-mapping": {
|
| 724 |
+
"version": "0.3.13",
|
| 725 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
| 726 |
+
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
| 727 |
+
"license": "MIT",
|
| 728 |
+
"dependencies": {
|
| 729 |
+
"@jridgewell/sourcemap-codec": "^1.5.0",
|
| 730 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 731 |
+
}
|
| 732 |
+
},
|
| 733 |
+
"node_modules/@jridgewell/remapping": {
|
| 734 |
+
"version": "2.3.5",
|
| 735 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
| 736 |
+
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
| 737 |
+
"license": "MIT",
|
| 738 |
+
"dependencies": {
|
| 739 |
+
"@jridgewell/gen-mapping": "^0.3.5",
|
| 740 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 741 |
+
}
|
| 742 |
+
},
|
| 743 |
+
"node_modules/@jridgewell/resolve-uri": {
|
| 744 |
+
"version": "3.1.2",
|
| 745 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
| 746 |
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
| 747 |
+
"license": "MIT",
|
| 748 |
+
"engines": {
|
| 749 |
+
"node": ">=6.0.0"
|
| 750 |
+
}
|
| 751 |
+
},
|
| 752 |
+
"node_modules/@jridgewell/sourcemap-codec": {
|
| 753 |
+
"version": "1.5.5",
|
| 754 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
| 755 |
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
| 756 |
+
"license": "MIT"
|
| 757 |
+
},
|
| 758 |
+
"node_modules/@jridgewell/trace-mapping": {
|
| 759 |
+
"version": "0.3.31",
|
| 760 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
| 761 |
+
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
| 762 |
+
"license": "MIT",
|
| 763 |
+
"dependencies": {
|
| 764 |
+
"@jridgewell/resolve-uri": "^3.1.0",
|
| 765 |
+
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 766 |
+
}
|
| 767 |
+
},
|
| 768 |
+
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
| 769 |
+
"version": "1.5.1",
|
| 770 |
+
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
| 771 |
+
"integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
|
| 772 |
+
"cpu": [
|
| 773 |
+
"x64"
|
| 774 |
+
],
|
| 775 |
+
"license": "MIT",
|
| 776 |
+
"optional": true,
|
| 777 |
+
"os": [
|
| 778 |
+
"linux"
|
| 779 |
+
],
|
| 780 |
+
"engines": {
|
| 781 |
+
"node": "^22.20 || ^24.12 || >=25"
|
| 782 |
+
}
|
| 783 |
+
},
|
| 784 |
+
"node_modules/@rolldown/pluginutils": {
|
| 785 |
+
"version": "1.0.0-rc.3",
|
| 786 |
+
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
| 787 |
+
"integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
|
| 788 |
+
"dev": true,
|
| 789 |
+
"license": "MIT"
|
| 790 |
+
},
|
| 791 |
+
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 792 |
+
"version": "4.63.0",
|
| 793 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz",
|
| 794 |
+
"integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==",
|
| 795 |
+
"cpu": [
|
| 796 |
+
"arm"
|
| 797 |
+
],
|
| 798 |
+
"license": "MIT",
|
| 799 |
+
"optional": true,
|
| 800 |
+
"os": [
|
| 801 |
+
"android"
|
| 802 |
+
]
|
| 803 |
+
},
|
| 804 |
+
"node_modules/@rollup/rollup-android-arm64": {
|
| 805 |
+
"version": "4.63.0",
|
| 806 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz",
|
| 807 |
+
"integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==",
|
| 808 |
+
"cpu": [
|
| 809 |
+
"arm64"
|
| 810 |
+
],
|
| 811 |
+
"license": "MIT",
|
| 812 |
+
"optional": true,
|
| 813 |
+
"os": [
|
| 814 |
+
"android"
|
| 815 |
+
]
|
| 816 |
+
},
|
| 817 |
+
"node_modules/@rollup/rollup-darwin-arm64": {
|
| 818 |
+
"version": "4.63.0",
|
| 819 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz",
|
| 820 |
+
"integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==",
|
| 821 |
+
"cpu": [
|
| 822 |
+
"arm64"
|
| 823 |
+
],
|
| 824 |
+
"license": "MIT",
|
| 825 |
+
"optional": true,
|
| 826 |
+
"os": [
|
| 827 |
+
"darwin"
|
| 828 |
+
]
|
| 829 |
+
},
|
| 830 |
+
"node_modules/@rollup/rollup-darwin-x64": {
|
| 831 |
+
"version": "4.63.0",
|
| 832 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz",
|
| 833 |
+
"integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==",
|
| 834 |
+
"cpu": [
|
| 835 |
+
"x64"
|
| 836 |
+
],
|
| 837 |
+
"license": "MIT",
|
| 838 |
+
"optional": true,
|
| 839 |
+
"os": [
|
| 840 |
+
"darwin"
|
| 841 |
+
]
|
| 842 |
+
},
|
| 843 |
+
"node_modules/@rollup/rollup-freebsd-arm64": {
|
| 844 |
+
"version": "4.63.0",
|
| 845 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz",
|
| 846 |
+
"integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==",
|
| 847 |
+
"cpu": [
|
| 848 |
+
"arm64"
|
| 849 |
+
],
|
| 850 |
+
"license": "MIT",
|
| 851 |
+
"optional": true,
|
| 852 |
+
"os": [
|
| 853 |
+
"freebsd"
|
| 854 |
+
]
|
| 855 |
+
},
|
| 856 |
+
"node_modules/@rollup/rollup-freebsd-x64": {
|
| 857 |
+
"version": "4.63.0",
|
| 858 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz",
|
| 859 |
+
"integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==",
|
| 860 |
+
"cpu": [
|
| 861 |
+
"x64"
|
| 862 |
+
],
|
| 863 |
+
"license": "MIT",
|
| 864 |
+
"optional": true,
|
| 865 |
+
"os": [
|
| 866 |
+
"freebsd"
|
| 867 |
+
]
|
| 868 |
+
},
|
| 869 |
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
| 870 |
+
"version": "4.63.0",
|
| 871 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz",
|
| 872 |
+
"integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==",
|
| 873 |
+
"cpu": [
|
| 874 |
+
"arm"
|
| 875 |
+
],
|
| 876 |
+
"license": "MIT",
|
| 877 |
+
"optional": true,
|
| 878 |
+
"os": [
|
| 879 |
+
"linux"
|
| 880 |
+
]
|
| 881 |
+
},
|
| 882 |
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
| 883 |
+
"version": "4.63.0",
|
| 884 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz",
|
| 885 |
+
"integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==",
|
| 886 |
+
"cpu": [
|
| 887 |
+
"arm"
|
| 888 |
+
],
|
| 889 |
+
"license": "MIT",
|
| 890 |
+
"optional": true,
|
| 891 |
+
"os": [
|
| 892 |
+
"linux"
|
| 893 |
+
]
|
| 894 |
+
},
|
| 895 |
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
| 896 |
+
"version": "4.63.0",
|
| 897 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz",
|
| 898 |
+
"integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==",
|
| 899 |
+
"cpu": [
|
| 900 |
+
"arm64"
|
| 901 |
+
],
|
| 902 |
+
"license": "MIT",
|
| 903 |
+
"optional": true,
|
| 904 |
+
"os": [
|
| 905 |
+
"linux"
|
| 906 |
+
]
|
| 907 |
+
},
|
| 908 |
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
| 909 |
+
"version": "4.63.0",
|
| 910 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz",
|
| 911 |
+
"integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==",
|
| 912 |
+
"cpu": [
|
| 913 |
+
"arm64"
|
| 914 |
+
],
|
| 915 |
+
"license": "MIT",
|
| 916 |
+
"optional": true,
|
| 917 |
+
"os": [
|
| 918 |
+
"linux"
|
| 919 |
+
]
|
| 920 |
+
},
|
| 921 |
+
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
| 922 |
+
"version": "4.63.0",
|
| 923 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz",
|
| 924 |
+
"integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==",
|
| 925 |
+
"cpu": [
|
| 926 |
+
"loong64"
|
| 927 |
+
],
|
| 928 |
+
"license": "MIT",
|
| 929 |
+
"optional": true,
|
| 930 |
+
"os": [
|
| 931 |
+
"linux"
|
| 932 |
+
]
|
| 933 |
+
},
|
| 934 |
+
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
| 935 |
+
"version": "4.63.0",
|
| 936 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz",
|
| 937 |
+
"integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==",
|
| 938 |
+
"cpu": [
|
| 939 |
+
"loong64"
|
| 940 |
+
],
|
| 941 |
+
"license": "MIT",
|
| 942 |
+
"optional": true,
|
| 943 |
+
"os": [
|
| 944 |
+
"linux"
|
| 945 |
+
]
|
| 946 |
+
},
|
| 947 |
+
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
| 948 |
+
"version": "4.63.0",
|
| 949 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz",
|
| 950 |
+
"integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==",
|
| 951 |
+
"cpu": [
|
| 952 |
+
"ppc64"
|
| 953 |
+
],
|
| 954 |
+
"license": "MIT",
|
| 955 |
+
"optional": true,
|
| 956 |
+
"os": [
|
| 957 |
+
"linux"
|
| 958 |
+
]
|
| 959 |
+
},
|
| 960 |
+
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
| 961 |
+
"version": "4.63.0",
|
| 962 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz",
|
| 963 |
+
"integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==",
|
| 964 |
+
"cpu": [
|
| 965 |
+
"ppc64"
|
| 966 |
+
],
|
| 967 |
+
"license": "MIT",
|
| 968 |
+
"optional": true,
|
| 969 |
+
"os": [
|
| 970 |
+
"linux"
|
| 971 |
+
]
|
| 972 |
+
},
|
| 973 |
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
| 974 |
+
"version": "4.63.0",
|
| 975 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz",
|
| 976 |
+
"integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==",
|
| 977 |
+
"cpu": [
|
| 978 |
+
"riscv64"
|
| 979 |
+
],
|
| 980 |
+
"license": "MIT",
|
| 981 |
+
"optional": true,
|
| 982 |
+
"os": [
|
| 983 |
+
"linux"
|
| 984 |
+
]
|
| 985 |
+
},
|
| 986 |
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
| 987 |
+
"version": "4.63.0",
|
| 988 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz",
|
| 989 |
+
"integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==",
|
| 990 |
+
"cpu": [
|
| 991 |
+
"riscv64"
|
| 992 |
+
],
|
| 993 |
+
"license": "MIT",
|
| 994 |
+
"optional": true,
|
| 995 |
+
"os": [
|
| 996 |
+
"linux"
|
| 997 |
+
]
|
| 998 |
+
},
|
| 999 |
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
| 1000 |
+
"version": "4.63.0",
|
| 1001 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz",
|
| 1002 |
+
"integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==",
|
| 1003 |
+
"cpu": [
|
| 1004 |
+
"s390x"
|
| 1005 |
+
],
|
| 1006 |
+
"license": "MIT",
|
| 1007 |
+
"optional": true,
|
| 1008 |
+
"os": [
|
| 1009 |
+
"linux"
|
| 1010 |
+
]
|
| 1011 |
+
},
|
| 1012 |
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
| 1013 |
+
"version": "4.63.0",
|
| 1014 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz",
|
| 1015 |
+
"integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==",
|
| 1016 |
+
"cpu": [
|
| 1017 |
+
"x64"
|
| 1018 |
+
],
|
| 1019 |
+
"license": "MIT",
|
| 1020 |
+
"optional": true,
|
| 1021 |
+
"os": [
|
| 1022 |
+
"linux"
|
| 1023 |
+
]
|
| 1024 |
+
},
|
| 1025 |
+
"node_modules/@rollup/rollup-linux-x64-musl": {
|
| 1026 |
+
"version": "4.63.0",
|
| 1027 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz",
|
| 1028 |
+
"integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==",
|
| 1029 |
+
"cpu": [
|
| 1030 |
+
"x64"
|
| 1031 |
+
],
|
| 1032 |
+
"license": "MIT",
|
| 1033 |
+
"optional": true,
|
| 1034 |
+
"os": [
|
| 1035 |
+
"linux"
|
| 1036 |
+
]
|
| 1037 |
+
},
|
| 1038 |
+
"node_modules/@rollup/rollup-openbsd-x64": {
|
| 1039 |
+
"version": "4.63.0",
|
| 1040 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz",
|
| 1041 |
+
"integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==",
|
| 1042 |
+
"cpu": [
|
| 1043 |
+
"x64"
|
| 1044 |
+
],
|
| 1045 |
+
"license": "MIT",
|
| 1046 |
+
"optional": true,
|
| 1047 |
+
"os": [
|
| 1048 |
+
"openbsd"
|
| 1049 |
+
]
|
| 1050 |
+
},
|
| 1051 |
+
"node_modules/@rollup/rollup-openharmony-arm64": {
|
| 1052 |
+
"version": "4.63.0",
|
| 1053 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz",
|
| 1054 |
+
"integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==",
|
| 1055 |
+
"cpu": [
|
| 1056 |
+
"arm64"
|
| 1057 |
+
],
|
| 1058 |
+
"license": "MIT",
|
| 1059 |
+
"optional": true,
|
| 1060 |
+
"os": [
|
| 1061 |
+
"openharmony"
|
| 1062 |
+
]
|
| 1063 |
+
},
|
| 1064 |
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
| 1065 |
+
"version": "4.63.0",
|
| 1066 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz",
|
| 1067 |
+
"integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==",
|
| 1068 |
+
"cpu": [
|
| 1069 |
+
"arm64"
|
| 1070 |
+
],
|
| 1071 |
+
"license": "MIT",
|
| 1072 |
+
"optional": true,
|
| 1073 |
+
"os": [
|
| 1074 |
+
"win32"
|
| 1075 |
+
]
|
| 1076 |
+
},
|
| 1077 |
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
| 1078 |
+
"version": "4.63.0",
|
| 1079 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz",
|
| 1080 |
+
"integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==",
|
| 1081 |
+
"cpu": [
|
| 1082 |
+
"ia32"
|
| 1083 |
+
],
|
| 1084 |
+
"license": "MIT",
|
| 1085 |
+
"optional": true,
|
| 1086 |
+
"os": [
|
| 1087 |
+
"win32"
|
| 1088 |
+
]
|
| 1089 |
+
},
|
| 1090 |
+
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
| 1091 |
+
"version": "4.63.0",
|
| 1092 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz",
|
| 1093 |
+
"integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==",
|
| 1094 |
+
"cpu": [
|
| 1095 |
+
"x64"
|
| 1096 |
+
],
|
| 1097 |
+
"license": "MIT",
|
| 1098 |
+
"optional": true,
|
| 1099 |
+
"os": [
|
| 1100 |
+
"win32"
|
| 1101 |
+
]
|
| 1102 |
+
},
|
| 1103 |
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
| 1104 |
+
"version": "4.63.0",
|
| 1105 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz",
|
| 1106 |
+
"integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==",
|
| 1107 |
+
"cpu": [
|
| 1108 |
+
"x64"
|
| 1109 |
+
],
|
| 1110 |
+
"license": "MIT",
|
| 1111 |
+
"optional": true,
|
| 1112 |
+
"os": [
|
| 1113 |
+
"win32"
|
| 1114 |
+
]
|
| 1115 |
+
},
|
| 1116 |
+
"node_modules/@tailwindcss/node": {
|
| 1117 |
+
"version": "4.3.3",
|
| 1118 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
| 1119 |
+
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
| 1120 |
+
"license": "MIT",
|
| 1121 |
+
"dependencies": {
|
| 1122 |
+
"@jridgewell/remapping": "^2.3.5",
|
| 1123 |
+
"enhanced-resolve": "^5.24.1",
|
| 1124 |
+
"jiti": "^2.7.0",
|
| 1125 |
+
"lightningcss": "1.32.0",
|
| 1126 |
+
"magic-string": "^0.30.21",
|
| 1127 |
+
"source-map-js": "^1.2.1",
|
| 1128 |
+
"tailwindcss": "4.3.3"
|
| 1129 |
+
}
|
| 1130 |
+
},
|
| 1131 |
+
"node_modules/@tailwindcss/oxide": {
|
| 1132 |
+
"version": "4.3.3",
|
| 1133 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
| 1134 |
+
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
| 1135 |
+
"license": "MIT",
|
| 1136 |
+
"engines": {
|
| 1137 |
+
"node": ">= 20"
|
| 1138 |
+
},
|
| 1139 |
+
"optionalDependencies": {
|
| 1140 |
+
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
| 1141 |
+
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
| 1142 |
+
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
| 1143 |
+
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
| 1144 |
+
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
| 1145 |
+
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
| 1146 |
+
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
| 1147 |
+
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
| 1148 |
+
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
| 1149 |
+
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
| 1150 |
+
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
| 1151 |
+
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
| 1152 |
+
}
|
| 1153 |
+
},
|
| 1154 |
+
"node_modules/@tailwindcss/oxide-android-arm64": {
|
| 1155 |
+
"version": "4.3.3",
|
| 1156 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
| 1157 |
+
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
| 1158 |
+
"cpu": [
|
| 1159 |
+
"arm64"
|
| 1160 |
+
],
|
| 1161 |
+
"license": "MIT",
|
| 1162 |
+
"optional": true,
|
| 1163 |
+
"os": [
|
| 1164 |
+
"android"
|
| 1165 |
+
],
|
| 1166 |
+
"engines": {
|
| 1167 |
+
"node": ">= 20"
|
| 1168 |
+
}
|
| 1169 |
+
},
|
| 1170 |
+
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
| 1171 |
+
"version": "4.3.3",
|
| 1172 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
| 1173 |
+
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
| 1174 |
+
"cpu": [
|
| 1175 |
+
"arm64"
|
| 1176 |
+
],
|
| 1177 |
+
"license": "MIT",
|
| 1178 |
+
"optional": true,
|
| 1179 |
+
"os": [
|
| 1180 |
+
"darwin"
|
| 1181 |
+
],
|
| 1182 |
+
"engines": {
|
| 1183 |
+
"node": ">= 20"
|
| 1184 |
+
}
|
| 1185 |
+
},
|
| 1186 |
+
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
| 1187 |
+
"version": "4.3.3",
|
| 1188 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
| 1189 |
+
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
| 1190 |
+
"cpu": [
|
| 1191 |
+
"x64"
|
| 1192 |
+
],
|
| 1193 |
+
"license": "MIT",
|
| 1194 |
+
"optional": true,
|
| 1195 |
+
"os": [
|
| 1196 |
+
"darwin"
|
| 1197 |
+
],
|
| 1198 |
+
"engines": {
|
| 1199 |
+
"node": ">= 20"
|
| 1200 |
+
}
|
| 1201 |
+
},
|
| 1202 |
+
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
| 1203 |
+
"version": "4.3.3",
|
| 1204 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
| 1205 |
+
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
| 1206 |
+
"cpu": [
|
| 1207 |
+
"x64"
|
| 1208 |
+
],
|
| 1209 |
+
"license": "MIT",
|
| 1210 |
+
"optional": true,
|
| 1211 |
+
"os": [
|
| 1212 |
+
"freebsd"
|
| 1213 |
+
],
|
| 1214 |
+
"engines": {
|
| 1215 |
+
"node": ">= 20"
|
| 1216 |
+
}
|
| 1217 |
+
},
|
| 1218 |
+
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
| 1219 |
+
"version": "4.3.3",
|
| 1220 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
| 1221 |
+
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
| 1222 |
+
"cpu": [
|
| 1223 |
+
"arm"
|
| 1224 |
+
],
|
| 1225 |
+
"license": "MIT",
|
| 1226 |
+
"optional": true,
|
| 1227 |
+
"os": [
|
| 1228 |
+
"linux"
|
| 1229 |
+
],
|
| 1230 |
+
"engines": {
|
| 1231 |
+
"node": ">= 20"
|
| 1232 |
+
}
|
| 1233 |
+
},
|
| 1234 |
+
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
| 1235 |
+
"version": "4.3.3",
|
| 1236 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
| 1237 |
+
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
| 1238 |
+
"cpu": [
|
| 1239 |
+
"arm64"
|
| 1240 |
+
],
|
| 1241 |
+
"license": "MIT",
|
| 1242 |
+
"optional": true,
|
| 1243 |
+
"os": [
|
| 1244 |
+
"linux"
|
| 1245 |
+
],
|
| 1246 |
+
"engines": {
|
| 1247 |
+
"node": ">= 20"
|
| 1248 |
+
}
|
| 1249 |
+
},
|
| 1250 |
+
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
| 1251 |
+
"version": "4.3.3",
|
| 1252 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
| 1253 |
+
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
| 1254 |
+
"cpu": [
|
| 1255 |
+
"arm64"
|
| 1256 |
+
],
|
| 1257 |
+
"license": "MIT",
|
| 1258 |
+
"optional": true,
|
| 1259 |
+
"os": [
|
| 1260 |
+
"linux"
|
| 1261 |
+
],
|
| 1262 |
+
"engines": {
|
| 1263 |
+
"node": ">= 20"
|
| 1264 |
+
}
|
| 1265 |
+
},
|
| 1266 |
+
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
| 1267 |
+
"version": "4.3.3",
|
| 1268 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
| 1269 |
+
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
| 1270 |
+
"cpu": [
|
| 1271 |
+
"x64"
|
| 1272 |
+
],
|
| 1273 |
+
"license": "MIT",
|
| 1274 |
+
"optional": true,
|
| 1275 |
+
"os": [
|
| 1276 |
+
"linux"
|
| 1277 |
+
],
|
| 1278 |
+
"engines": {
|
| 1279 |
+
"node": ">= 20"
|
| 1280 |
+
}
|
| 1281 |
+
},
|
| 1282 |
+
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
| 1283 |
+
"version": "4.3.3",
|
| 1284 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
| 1285 |
+
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
| 1286 |
+
"cpu": [
|
| 1287 |
+
"x64"
|
| 1288 |
+
],
|
| 1289 |
+
"license": "MIT",
|
| 1290 |
+
"optional": true,
|
| 1291 |
+
"os": [
|
| 1292 |
+
"linux"
|
| 1293 |
+
],
|
| 1294 |
+
"engines": {
|
| 1295 |
+
"node": ">= 20"
|
| 1296 |
+
}
|
| 1297 |
+
},
|
| 1298 |
+
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
| 1299 |
+
"version": "4.3.3",
|
| 1300 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
| 1301 |
+
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
| 1302 |
+
"bundleDependencies": [
|
| 1303 |
+
"@napi-rs/wasm-runtime",
|
| 1304 |
+
"@emnapi/core",
|
| 1305 |
+
"@emnapi/runtime",
|
| 1306 |
+
"@tybys/wasm-util",
|
| 1307 |
+
"@emnapi/wasi-threads",
|
| 1308 |
+
"tslib"
|
| 1309 |
+
],
|
| 1310 |
+
"cpu": [
|
| 1311 |
+
"wasm32"
|
| 1312 |
+
],
|
| 1313 |
+
"license": "MIT",
|
| 1314 |
+
"optional": true,
|
| 1315 |
+
"dependencies": {
|
| 1316 |
+
"@emnapi/core": "^1.11.1",
|
| 1317 |
+
"@emnapi/runtime": "^1.11.1",
|
| 1318 |
+
"@emnapi/wasi-threads": "^1.2.2",
|
| 1319 |
+
"@napi-rs/wasm-runtime": "^1.1.4",
|
| 1320 |
+
"@tybys/wasm-util": "^0.10.2",
|
| 1321 |
+
"tslib": "^2.8.1"
|
| 1322 |
+
},
|
| 1323 |
+
"engines": {
|
| 1324 |
+
"node": ">=14.0.0"
|
| 1325 |
+
}
|
| 1326 |
+
},
|
| 1327 |
+
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
| 1328 |
+
"version": "4.3.3",
|
| 1329 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
| 1330 |
+
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
| 1331 |
+
"cpu": [
|
| 1332 |
+
"arm64"
|
| 1333 |
+
],
|
| 1334 |
+
"license": "MIT",
|
| 1335 |
+
"optional": true,
|
| 1336 |
+
"os": [
|
| 1337 |
+
"win32"
|
| 1338 |
+
],
|
| 1339 |
+
"engines": {
|
| 1340 |
+
"node": ">= 20"
|
| 1341 |
+
}
|
| 1342 |
+
},
|
| 1343 |
+
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
| 1344 |
+
"version": "4.3.3",
|
| 1345 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
| 1346 |
+
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
| 1347 |
+
"cpu": [
|
| 1348 |
+
"x64"
|
| 1349 |
+
],
|
| 1350 |
+
"license": "MIT",
|
| 1351 |
+
"optional": true,
|
| 1352 |
+
"os": [
|
| 1353 |
+
"win32"
|
| 1354 |
+
],
|
| 1355 |
+
"engines": {
|
| 1356 |
+
"node": ">= 20"
|
| 1357 |
+
}
|
| 1358 |
+
},
|
| 1359 |
+
"node_modules/@tailwindcss/vite": {
|
| 1360 |
+
"version": "4.3.3",
|
| 1361 |
+
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
| 1362 |
+
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
|
| 1363 |
+
"license": "MIT",
|
| 1364 |
+
"dependencies": {
|
| 1365 |
+
"@tailwindcss/node": "4.3.3",
|
| 1366 |
+
"@tailwindcss/oxide": "4.3.3",
|
| 1367 |
+
"tailwindcss": "4.3.3"
|
| 1368 |
+
},
|
| 1369 |
+
"peerDependencies": {
|
| 1370 |
+
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
| 1371 |
+
}
|
| 1372 |
+
},
|
| 1373 |
+
"node_modules/@types/babel__core": {
|
| 1374 |
+
"version": "7.20.5",
|
| 1375 |
+
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
| 1376 |
+
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
| 1377 |
+
"dev": true,
|
| 1378 |
+
"license": "MIT",
|
| 1379 |
+
"dependencies": {
|
| 1380 |
+
"@babel/parser": "^7.20.7",
|
| 1381 |
+
"@babel/types": "^7.20.7",
|
| 1382 |
+
"@types/babel__generator": "*",
|
| 1383 |
+
"@types/babel__template": "*",
|
| 1384 |
+
"@types/babel__traverse": "*"
|
| 1385 |
+
}
|
| 1386 |
+
},
|
| 1387 |
+
"node_modules/@types/babel__generator": {
|
| 1388 |
+
"version": "7.27.0",
|
| 1389 |
+
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
| 1390 |
+
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
| 1391 |
+
"dev": true,
|
| 1392 |
+
"license": "MIT",
|
| 1393 |
+
"dependencies": {
|
| 1394 |
+
"@babel/types": "^7.0.0"
|
| 1395 |
+
}
|
| 1396 |
+
},
|
| 1397 |
+
"node_modules/@types/babel__template": {
|
| 1398 |
+
"version": "7.4.4",
|
| 1399 |
+
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
| 1400 |
+
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
| 1401 |
+
"dev": true,
|
| 1402 |
+
"license": "MIT",
|
| 1403 |
+
"dependencies": {
|
| 1404 |
+
"@babel/parser": "^7.1.0",
|
| 1405 |
+
"@babel/types": "^7.0.0"
|
| 1406 |
+
}
|
| 1407 |
+
},
|
| 1408 |
+
"node_modules/@types/babel__traverse": {
|
| 1409 |
+
"version": "7.28.0",
|
| 1410 |
+
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
| 1411 |
+
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
| 1412 |
+
"dev": true,
|
| 1413 |
+
"license": "MIT",
|
| 1414 |
+
"dependencies": {
|
| 1415 |
+
"@babel/types": "^7.28.2"
|
| 1416 |
+
}
|
| 1417 |
+
},
|
| 1418 |
+
"node_modules/@types/estree": {
|
| 1419 |
+
"version": "1.0.9",
|
| 1420 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
| 1421 |
+
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
| 1422 |
+
"license": "MIT"
|
| 1423 |
+
},
|
| 1424 |
+
"node_modules/@types/node": {
|
| 1425 |
+
"version": "22.20.1",
|
| 1426 |
+
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
| 1427 |
+
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
| 1428 |
+
"devOptional": true,
|
| 1429 |
+
"license": "MIT",
|
| 1430 |
+
"dependencies": {
|
| 1431 |
+
"undici-types": "~6.21.0"
|
| 1432 |
+
}
|
| 1433 |
+
},
|
| 1434 |
+
"node_modules/@types/react": {
|
| 1435 |
+
"version": "19.2.18",
|
| 1436 |
+
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
| 1437 |
+
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
| 1438 |
+
"dev": true,
|
| 1439 |
+
"license": "MIT",
|
| 1440 |
+
"dependencies": {
|
| 1441 |
+
"csstype": "^3.2.2"
|
| 1442 |
+
}
|
| 1443 |
+
},
|
| 1444 |
+
"node_modules/@types/react-dom": {
|
| 1445 |
+
"version": "19.2.5",
|
| 1446 |
+
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
|
| 1447 |
+
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
|
| 1448 |
+
"dev": true,
|
| 1449 |
+
"license": "MIT",
|
| 1450 |
+
"peerDependencies": {
|
| 1451 |
+
"@types/react": "^19.2.0"
|
| 1452 |
+
}
|
| 1453 |
+
},
|
| 1454 |
+
"node_modules/@vitejs/plugin-react": {
|
| 1455 |
+
"version": "5.2.0",
|
| 1456 |
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
| 1457 |
+
"integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
|
| 1458 |
+
"dev": true,
|
| 1459 |
+
"license": "MIT",
|
| 1460 |
+
"dependencies": {
|
| 1461 |
+
"@babel/core": "^7.29.0",
|
| 1462 |
+
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
| 1463 |
+
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
| 1464 |
+
"@rolldown/pluginutils": "1.0.0-rc.3",
|
| 1465 |
+
"@types/babel__core": "^7.20.5",
|
| 1466 |
+
"react-refresh": "^0.18.0"
|
| 1467 |
+
},
|
| 1468 |
+
"engines": {
|
| 1469 |
+
"node": "^20.19.0 || >=22.12.0"
|
| 1470 |
+
},
|
| 1471 |
+
"peerDependencies": {
|
| 1472 |
+
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
| 1473 |
+
}
|
| 1474 |
+
},
|
| 1475 |
+
"node_modules/baseline-browser-mapping": {
|
| 1476 |
+
"version": "2.11.20",
|
| 1477 |
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
|
| 1478 |
+
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
|
| 1479 |
+
"dev": true,
|
| 1480 |
+
"license": "Apache-2.0",
|
| 1481 |
+
"bin": {
|
| 1482 |
+
"baseline-browser-mapping": "dist/cli.cjs"
|
| 1483 |
+
},
|
| 1484 |
+
"engines": {
|
| 1485 |
+
"node": ">=6.0.0"
|
| 1486 |
+
}
|
| 1487 |
+
},
|
| 1488 |
+
"node_modules/browserslist": {
|
| 1489 |
+
"version": "4.28.8",
|
| 1490 |
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
| 1491 |
+
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
|
| 1492 |
+
"dev": true,
|
| 1493 |
+
"funding": [
|
| 1494 |
+
{
|
| 1495 |
+
"type": "opencollective",
|
| 1496 |
+
"url": "https://opencollective.com/browserslist"
|
| 1497 |
+
},
|
| 1498 |
+
{
|
| 1499 |
+
"type": "tidelift",
|
| 1500 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1501 |
+
},
|
| 1502 |
+
{
|
| 1503 |
+
"type": "github",
|
| 1504 |
+
"url": "https://github.com/sponsors/ai"
|
| 1505 |
+
}
|
| 1506 |
+
],
|
| 1507 |
+
"license": "MIT",
|
| 1508 |
+
"dependencies": {
|
| 1509 |
+
"baseline-browser-mapping": "^2.11.12",
|
| 1510 |
+
"caniuse-lite": "^1.0.30001809",
|
| 1511 |
+
"electron-to-chromium": "^1.5.402",
|
| 1512 |
+
"node-releases": "^2.0.53",
|
| 1513 |
+
"update-browserslist-db": "^1.3.0"
|
| 1514 |
+
},
|
| 1515 |
+
"bin": {
|
| 1516 |
+
"browserslist": "cli.js"
|
| 1517 |
+
},
|
| 1518 |
+
"engines": {
|
| 1519 |
+
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
| 1520 |
+
}
|
| 1521 |
+
},
|
| 1522 |
+
"node_modules/caniuse-lite": {
|
| 1523 |
+
"version": "1.0.30001810",
|
| 1524 |
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
| 1525 |
+
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
|
| 1526 |
+
"dev": true,
|
| 1527 |
+
"funding": [
|
| 1528 |
+
{
|
| 1529 |
+
"type": "opencollective",
|
| 1530 |
+
"url": "https://opencollective.com/browserslist"
|
| 1531 |
+
},
|
| 1532 |
+
{
|
| 1533 |
+
"type": "tidelift",
|
| 1534 |
+
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
| 1535 |
+
},
|
| 1536 |
+
{
|
| 1537 |
+
"type": "github",
|
| 1538 |
+
"url": "https://github.com/sponsors/ai"
|
| 1539 |
+
}
|
| 1540 |
+
],
|
| 1541 |
+
"license": "CC-BY-4.0"
|
| 1542 |
+
},
|
| 1543 |
+
"node_modules/convert-source-map": {
|
| 1544 |
+
"version": "2.0.0",
|
| 1545 |
+
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
| 1546 |
+
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
| 1547 |
+
"dev": true,
|
| 1548 |
+
"license": "MIT"
|
| 1549 |
+
},
|
| 1550 |
+
"node_modules/csstype": {
|
| 1551 |
+
"version": "3.2.3",
|
| 1552 |
+
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 1553 |
+
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 1554 |
+
"dev": true,
|
| 1555 |
+
"license": "MIT"
|
| 1556 |
+
},
|
| 1557 |
+
"node_modules/debug": {
|
| 1558 |
+
"version": "4.4.3",
|
| 1559 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
| 1560 |
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
| 1561 |
+
"dev": true,
|
| 1562 |
+
"license": "MIT",
|
| 1563 |
+
"dependencies": {
|
| 1564 |
+
"ms": "^2.1.3"
|
| 1565 |
+
},
|
| 1566 |
+
"engines": {
|
| 1567 |
+
"node": ">=6.0"
|
| 1568 |
+
},
|
| 1569 |
+
"peerDependenciesMeta": {
|
| 1570 |
+
"supports-color": {
|
| 1571 |
+
"optional": true
|
| 1572 |
+
}
|
| 1573 |
+
}
|
| 1574 |
+
},
|
| 1575 |
+
"node_modules/detect-libc": {
|
| 1576 |
+
"version": "2.1.2",
|
| 1577 |
+
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
| 1578 |
+
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
| 1579 |
+
"license": "Apache-2.0",
|
| 1580 |
+
"engines": {
|
| 1581 |
+
"node": ">=8"
|
| 1582 |
+
}
|
| 1583 |
+
},
|
| 1584 |
+
"node_modules/electron-to-chromium": {
|
| 1585 |
+
"version": "1.5.415",
|
| 1586 |
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz",
|
| 1587 |
+
"integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==",
|
| 1588 |
+
"dev": true,
|
| 1589 |
+
"license": "ISC"
|
| 1590 |
+
},
|
| 1591 |
+
"node_modules/enhanced-resolve": {
|
| 1592 |
+
"version": "5.24.5",
|
| 1593 |
+
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
| 1594 |
+
"integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
|
| 1595 |
+
"license": "MIT",
|
| 1596 |
+
"dependencies": {
|
| 1597 |
+
"graceful-fs": "^4.2.4",
|
| 1598 |
+
"tapable": "^2.3.3"
|
| 1599 |
+
},
|
| 1600 |
+
"engines": {
|
| 1601 |
+
"node": ">=10.13.0"
|
| 1602 |
+
}
|
| 1603 |
+
},
|
| 1604 |
+
"node_modules/esbuild": {
|
| 1605 |
+
"version": "0.28.2",
|
| 1606 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
| 1607 |
+
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
| 1608 |
+
"hasInstallScript": true,
|
| 1609 |
+
"license": "MIT",
|
| 1610 |
+
"bin": {
|
| 1611 |
+
"esbuild": "bin/esbuild"
|
| 1612 |
+
},
|
| 1613 |
+
"engines": {
|
| 1614 |
+
"node": ">=18"
|
| 1615 |
+
},
|
| 1616 |
+
"optionalDependencies": {
|
| 1617 |
+
"@esbuild/aix-ppc64": "0.28.2",
|
| 1618 |
+
"@esbuild/android-arm": "0.28.2",
|
| 1619 |
+
"@esbuild/android-arm64": "0.28.2",
|
| 1620 |
+
"@esbuild/android-x64": "0.28.2",
|
| 1621 |
+
"@esbuild/darwin-arm64": "0.28.2",
|
| 1622 |
+
"@esbuild/darwin-x64": "0.28.2",
|
| 1623 |
+
"@esbuild/freebsd-arm64": "0.28.2",
|
| 1624 |
+
"@esbuild/freebsd-x64": "0.28.2",
|
| 1625 |
+
"@esbuild/linux-arm": "0.28.2",
|
| 1626 |
+
"@esbuild/linux-arm64": "0.28.2",
|
| 1627 |
+
"@esbuild/linux-ia32": "0.28.2",
|
| 1628 |
+
"@esbuild/linux-loong64": "0.28.2",
|
| 1629 |
+
"@esbuild/linux-mips64el": "0.28.2",
|
| 1630 |
+
"@esbuild/linux-ppc64": "0.28.2",
|
| 1631 |
+
"@esbuild/linux-riscv64": "0.28.2",
|
| 1632 |
+
"@esbuild/linux-s390x": "0.28.2",
|
| 1633 |
+
"@esbuild/linux-x64": "0.28.2",
|
| 1634 |
+
"@esbuild/netbsd-arm64": "0.28.2",
|
| 1635 |
+
"@esbuild/netbsd-x64": "0.28.2",
|
| 1636 |
+
"@esbuild/openbsd-arm64": "0.28.2",
|
| 1637 |
+
"@esbuild/openbsd-x64": "0.28.2",
|
| 1638 |
+
"@esbuild/openharmony-arm64": "0.28.2",
|
| 1639 |
+
"@esbuild/sunos-x64": "0.28.2",
|
| 1640 |
+
"@esbuild/win32-arm64": "0.28.2",
|
| 1641 |
+
"@esbuild/win32-ia32": "0.28.2",
|
| 1642 |
+
"@esbuild/win32-x64": "0.28.2"
|
| 1643 |
+
}
|
| 1644 |
+
},
|
| 1645 |
+
"node_modules/escalade": {
|
| 1646 |
+
"version": "3.2.0",
|
| 1647 |
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
| 1648 |
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
| 1649 |
+
"dev": true,
|
| 1650 |
+
"license": "MIT",
|
| 1651 |
+
"engines": {
|
| 1652 |
+
"node": ">=6"
|
| 1653 |
+
}
|
| 1654 |
+
},
|
| 1655 |
+
"node_modules/fdir": {
|
| 1656 |
+
"version": "6.5.0",
|
| 1657 |
+
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
| 1658 |
+
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
| 1659 |
+
"license": "MIT",
|
| 1660 |
+
"engines": {
|
| 1661 |
+
"node": ">=12.0.0"
|
| 1662 |
+
},
|
| 1663 |
+
"peerDependencies": {
|
| 1664 |
+
"picomatch": "^3 || ^4"
|
| 1665 |
+
},
|
| 1666 |
+
"peerDependenciesMeta": {
|
| 1667 |
+
"picomatch": {
|
| 1668 |
+
"optional": true
|
| 1669 |
+
}
|
| 1670 |
+
}
|
| 1671 |
+
},
|
| 1672 |
+
"node_modules/fsevents": {
|
| 1673 |
+
"version": "2.3.3",
|
| 1674 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 1675 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 1676 |
+
"hasInstallScript": true,
|
| 1677 |
+
"license": "MIT",
|
| 1678 |
+
"optional": true,
|
| 1679 |
+
"os": [
|
| 1680 |
+
"darwin"
|
| 1681 |
+
],
|
| 1682 |
+
"engines": {
|
| 1683 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1684 |
+
}
|
| 1685 |
+
},
|
| 1686 |
+
"node_modules/gensync": {
|
| 1687 |
+
"version": "1.0.0-beta.2",
|
| 1688 |
+
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
| 1689 |
+
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
| 1690 |
+
"dev": true,
|
| 1691 |
+
"license": "MIT",
|
| 1692 |
+
"engines": {
|
| 1693 |
+
"node": ">=6.9.0"
|
| 1694 |
+
}
|
| 1695 |
+
},
|
| 1696 |
+
"node_modules/graceful-fs": {
|
| 1697 |
+
"version": "4.2.11",
|
| 1698 |
+
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
| 1699 |
+
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
| 1700 |
+
"license": "ISC"
|
| 1701 |
+
},
|
| 1702 |
+
"node_modules/jiti": {
|
| 1703 |
+
"version": "2.7.0",
|
| 1704 |
+
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
| 1705 |
+
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
| 1706 |
+
"license": "MIT",
|
| 1707 |
+
"bin": {
|
| 1708 |
+
"jiti": "lib/jiti-cli.mjs"
|
| 1709 |
+
}
|
| 1710 |
+
},
|
| 1711 |
+
"node_modules/js-tokens": {
|
| 1712 |
+
"version": "4.0.0",
|
| 1713 |
+
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
| 1714 |
+
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
| 1715 |
+
"dev": true,
|
| 1716 |
+
"license": "MIT"
|
| 1717 |
+
},
|
| 1718 |
+
"node_modules/jsesc": {
|
| 1719 |
+
"version": "3.1.0",
|
| 1720 |
+
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
| 1721 |
+
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
| 1722 |
+
"dev": true,
|
| 1723 |
+
"license": "MIT",
|
| 1724 |
+
"bin": {
|
| 1725 |
+
"jsesc": "bin/jsesc"
|
| 1726 |
+
},
|
| 1727 |
+
"engines": {
|
| 1728 |
+
"node": ">=6"
|
| 1729 |
+
}
|
| 1730 |
+
},
|
| 1731 |
+
"node_modules/json5": {
|
| 1732 |
+
"version": "2.2.3",
|
| 1733 |
+
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
| 1734 |
+
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
| 1735 |
+
"dev": true,
|
| 1736 |
+
"license": "MIT",
|
| 1737 |
+
"bin": {
|
| 1738 |
+
"json5": "lib/cli.js"
|
| 1739 |
+
},
|
| 1740 |
+
"engines": {
|
| 1741 |
+
"node": ">=6"
|
| 1742 |
+
}
|
| 1743 |
+
},
|
| 1744 |
+
"node_modules/lightningcss": {
|
| 1745 |
+
"version": "1.32.0",
|
| 1746 |
+
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
| 1747 |
+
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
| 1748 |
+
"license": "MPL-2.0",
|
| 1749 |
+
"dependencies": {
|
| 1750 |
+
"detect-libc": "^2.0.3"
|
| 1751 |
+
},
|
| 1752 |
+
"engines": {
|
| 1753 |
+
"node": ">= 12.0.0"
|
| 1754 |
+
},
|
| 1755 |
+
"funding": {
|
| 1756 |
+
"type": "opencollective",
|
| 1757 |
+
"url": "https://opencollective.com/parcel"
|
| 1758 |
+
},
|
| 1759 |
+
"optionalDependencies": {
|
| 1760 |
+
"lightningcss-android-arm64": "1.32.0",
|
| 1761 |
+
"lightningcss-darwin-arm64": "1.32.0",
|
| 1762 |
+
"lightningcss-darwin-x64": "1.32.0",
|
| 1763 |
+
"lightningcss-freebsd-x64": "1.32.0",
|
| 1764 |
+
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
| 1765 |
+
"lightningcss-linux-arm64-gnu": "1.32.0",
|
| 1766 |
+
"lightningcss-linux-arm64-musl": "1.32.0",
|
| 1767 |
+
"lightningcss-linux-x64-gnu": "1.32.0",
|
| 1768 |
+
"lightningcss-linux-x64-musl": "1.32.0",
|
| 1769 |
+
"lightningcss-win32-arm64-msvc": "1.32.0",
|
| 1770 |
+
"lightningcss-win32-x64-msvc": "1.32.0"
|
| 1771 |
+
}
|
| 1772 |
+
},
|
| 1773 |
+
"node_modules/lightningcss-android-arm64": {
|
| 1774 |
+
"version": "1.32.0",
|
| 1775 |
+
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
| 1776 |
+
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
| 1777 |
+
"cpu": [
|
| 1778 |
+
"arm64"
|
| 1779 |
+
],
|
| 1780 |
+
"license": "MPL-2.0",
|
| 1781 |
+
"optional": true,
|
| 1782 |
+
"os": [
|
| 1783 |
+
"android"
|
| 1784 |
+
],
|
| 1785 |
+
"engines": {
|
| 1786 |
+
"node": ">= 12.0.0"
|
| 1787 |
+
},
|
| 1788 |
+
"funding": {
|
| 1789 |
+
"type": "opencollective",
|
| 1790 |
+
"url": "https://opencollective.com/parcel"
|
| 1791 |
+
}
|
| 1792 |
+
},
|
| 1793 |
+
"node_modules/lightningcss-darwin-arm64": {
|
| 1794 |
+
"version": "1.32.0",
|
| 1795 |
+
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
| 1796 |
+
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
| 1797 |
+
"cpu": [
|
| 1798 |
+
"arm64"
|
| 1799 |
+
],
|
| 1800 |
+
"license": "MPL-2.0",
|
| 1801 |
+
"optional": true,
|
| 1802 |
+
"os": [
|
| 1803 |
+
"darwin"
|
| 1804 |
+
],
|
| 1805 |
+
"engines": {
|
| 1806 |
+
"node": ">= 12.0.0"
|
| 1807 |
+
},
|
| 1808 |
+
"funding": {
|
| 1809 |
+
"type": "opencollective",
|
| 1810 |
+
"url": "https://opencollective.com/parcel"
|
| 1811 |
+
}
|
| 1812 |
+
},
|
| 1813 |
+
"node_modules/lightningcss-darwin-x64": {
|
| 1814 |
+
"version": "1.32.0",
|
| 1815 |
+
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
| 1816 |
+
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
| 1817 |
+
"cpu": [
|
| 1818 |
+
"x64"
|
| 1819 |
+
],
|
| 1820 |
+
"license": "MPL-2.0",
|
| 1821 |
+
"optional": true,
|
| 1822 |
+
"os": [
|
| 1823 |
+
"darwin"
|
| 1824 |
+
],
|
| 1825 |
+
"engines": {
|
| 1826 |
+
"node": ">= 12.0.0"
|
| 1827 |
+
},
|
| 1828 |
+
"funding": {
|
| 1829 |
+
"type": "opencollective",
|
| 1830 |
+
"url": "https://opencollective.com/parcel"
|
| 1831 |
+
}
|
| 1832 |
+
},
|
| 1833 |
+
"node_modules/lightningcss-freebsd-x64": {
|
| 1834 |
+
"version": "1.32.0",
|
| 1835 |
+
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
| 1836 |
+
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
| 1837 |
+
"cpu": [
|
| 1838 |
+
"x64"
|
| 1839 |
+
],
|
| 1840 |
+
"license": "MPL-2.0",
|
| 1841 |
+
"optional": true,
|
| 1842 |
+
"os": [
|
| 1843 |
+
"freebsd"
|
| 1844 |
+
],
|
| 1845 |
+
"engines": {
|
| 1846 |
+
"node": ">= 12.0.0"
|
| 1847 |
+
},
|
| 1848 |
+
"funding": {
|
| 1849 |
+
"type": "opencollective",
|
| 1850 |
+
"url": "https://opencollective.com/parcel"
|
| 1851 |
+
}
|
| 1852 |
+
},
|
| 1853 |
+
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
| 1854 |
+
"version": "1.32.0",
|
| 1855 |
+
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
| 1856 |
+
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
| 1857 |
+
"cpu": [
|
| 1858 |
+
"arm"
|
| 1859 |
+
],
|
| 1860 |
+
"license": "MPL-2.0",
|
| 1861 |
+
"optional": true,
|
| 1862 |
+
"os": [
|
| 1863 |
+
"linux"
|
| 1864 |
+
],
|
| 1865 |
+
"engines": {
|
| 1866 |
+
"node": ">= 12.0.0"
|
| 1867 |
+
},
|
| 1868 |
+
"funding": {
|
| 1869 |
+
"type": "opencollective",
|
| 1870 |
+
"url": "https://opencollective.com/parcel"
|
| 1871 |
+
}
|
| 1872 |
+
},
|
| 1873 |
+
"node_modules/lightningcss-linux-arm64-gnu": {
|
| 1874 |
+
"version": "1.32.0",
|
| 1875 |
+
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
| 1876 |
+
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
| 1877 |
+
"cpu": [
|
| 1878 |
+
"arm64"
|
| 1879 |
+
],
|
| 1880 |
+
"license": "MPL-2.0",
|
| 1881 |
+
"optional": true,
|
| 1882 |
+
"os": [
|
| 1883 |
+
"linux"
|
| 1884 |
+
],
|
| 1885 |
+
"engines": {
|
| 1886 |
+
"node": ">= 12.0.0"
|
| 1887 |
+
},
|
| 1888 |
+
"funding": {
|
| 1889 |
+
"type": "opencollective",
|
| 1890 |
+
"url": "https://opencollective.com/parcel"
|
| 1891 |
+
}
|
| 1892 |
+
},
|
| 1893 |
+
"node_modules/lightningcss-linux-arm64-musl": {
|
| 1894 |
+
"version": "1.32.0",
|
| 1895 |
+
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
| 1896 |
+
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
| 1897 |
+
"cpu": [
|
| 1898 |
+
"arm64"
|
| 1899 |
+
],
|
| 1900 |
+
"license": "MPL-2.0",
|
| 1901 |
+
"optional": true,
|
| 1902 |
+
"os": [
|
| 1903 |
+
"linux"
|
| 1904 |
+
],
|
| 1905 |
+
"engines": {
|
| 1906 |
+
"node": ">= 12.0.0"
|
| 1907 |
+
},
|
| 1908 |
+
"funding": {
|
| 1909 |
+
"type": "opencollective",
|
| 1910 |
+
"url": "https://opencollective.com/parcel"
|
| 1911 |
+
}
|
| 1912 |
+
},
|
| 1913 |
+
"node_modules/lightningcss-linux-x64-gnu": {
|
| 1914 |
+
"version": "1.32.0",
|
| 1915 |
+
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
| 1916 |
+
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
| 1917 |
+
"cpu": [
|
| 1918 |
+
"x64"
|
| 1919 |
+
],
|
| 1920 |
+
"license": "MPL-2.0",
|
| 1921 |
+
"optional": true,
|
| 1922 |
+
"os": [
|
| 1923 |
+
"linux"
|
| 1924 |
+
],
|
| 1925 |
+
"engines": {
|
| 1926 |
+
"node": ">= 12.0.0"
|
| 1927 |
+
},
|
| 1928 |
+
"funding": {
|
| 1929 |
+
"type": "opencollective",
|
| 1930 |
+
"url": "https://opencollective.com/parcel"
|
| 1931 |
+
}
|
| 1932 |
+
},
|
| 1933 |
+
"node_modules/lightningcss-linux-x64-musl": {
|
| 1934 |
+
"version": "1.32.0",
|
| 1935 |
+
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
| 1936 |
+
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
| 1937 |
+
"cpu": [
|
| 1938 |
+
"x64"
|
| 1939 |
+
],
|
| 1940 |
+
"license": "MPL-2.0",
|
| 1941 |
+
"optional": true,
|
| 1942 |
+
"os": [
|
| 1943 |
+
"linux"
|
| 1944 |
+
],
|
| 1945 |
+
"engines": {
|
| 1946 |
+
"node": ">= 12.0.0"
|
| 1947 |
+
},
|
| 1948 |
+
"funding": {
|
| 1949 |
+
"type": "opencollective",
|
| 1950 |
+
"url": "https://opencollective.com/parcel"
|
| 1951 |
+
}
|
| 1952 |
+
},
|
| 1953 |
+
"node_modules/lightningcss-win32-arm64-msvc": {
|
| 1954 |
+
"version": "1.32.0",
|
| 1955 |
+
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
| 1956 |
+
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
| 1957 |
+
"cpu": [
|
| 1958 |
+
"arm64"
|
| 1959 |
+
],
|
| 1960 |
+
"license": "MPL-2.0",
|
| 1961 |
+
"optional": true,
|
| 1962 |
+
"os": [
|
| 1963 |
+
"win32"
|
| 1964 |
+
],
|
| 1965 |
+
"engines": {
|
| 1966 |
+
"node": ">= 12.0.0"
|
| 1967 |
+
},
|
| 1968 |
+
"funding": {
|
| 1969 |
+
"type": "opencollective",
|
| 1970 |
+
"url": "https://opencollective.com/parcel"
|
| 1971 |
+
}
|
| 1972 |
+
},
|
| 1973 |
+
"node_modules/lightningcss-win32-x64-msvc": {
|
| 1974 |
+
"version": "1.32.0",
|
| 1975 |
+
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
| 1976 |
+
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
| 1977 |
+
"cpu": [
|
| 1978 |
+
"x64"
|
| 1979 |
+
],
|
| 1980 |
+
"license": "MPL-2.0",
|
| 1981 |
+
"optional": true,
|
| 1982 |
+
"os": [
|
| 1983 |
+
"win32"
|
| 1984 |
+
],
|
| 1985 |
+
"engines": {
|
| 1986 |
+
"node": ">= 12.0.0"
|
| 1987 |
+
},
|
| 1988 |
+
"funding": {
|
| 1989 |
+
"type": "opencollective",
|
| 1990 |
+
"url": "https://opencollective.com/parcel"
|
| 1991 |
+
}
|
| 1992 |
+
},
|
| 1993 |
+
"node_modules/lru-cache": {
|
| 1994 |
+
"version": "5.1.1",
|
| 1995 |
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
| 1996 |
+
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
| 1997 |
+
"dev": true,
|
| 1998 |
+
"license": "ISC",
|
| 1999 |
+
"dependencies": {
|
| 2000 |
+
"yallist": "^3.0.2"
|
| 2001 |
+
}
|
| 2002 |
+
},
|
| 2003 |
+
"node_modules/magic-string": {
|
| 2004 |
+
"version": "0.30.21",
|
| 2005 |
+
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
| 2006 |
+
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
| 2007 |
+
"license": "MIT",
|
| 2008 |
+
"dependencies": {
|
| 2009 |
+
"@jridgewell/sourcemap-codec": "^1.5.5"
|
| 2010 |
+
}
|
| 2011 |
+
},
|
| 2012 |
+
"node_modules/ms": {
|
| 2013 |
+
"version": "2.1.3",
|
| 2014 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 2015 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 2016 |
+
"dev": true,
|
| 2017 |
+
"license": "MIT"
|
| 2018 |
+
},
|
| 2019 |
+
"node_modules/nanoid": {
|
| 2020 |
+
"version": "3.3.18",
|
| 2021 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
| 2022 |
+
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
| 2023 |
+
"funding": [
|
| 2024 |
+
{
|
| 2025 |
+
"type": "github",
|
| 2026 |
+
"url": "https://github.com/sponsors/ai"
|
| 2027 |
+
}
|
| 2028 |
+
],
|
| 2029 |
+
"license": "MIT",
|
| 2030 |
+
"bin": {
|
| 2031 |
+
"nanoid": "bin/nanoid.cjs"
|
| 2032 |
+
},
|
| 2033 |
+
"engines": {
|
| 2034 |
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
| 2035 |
+
}
|
| 2036 |
+
},
|
| 2037 |
+
"node_modules/node-releases": {
|
| 2038 |
+
"version": "2.0.54",
|
| 2039 |
+
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
| 2040 |
+
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
|
| 2041 |
+
"dev": true,
|
| 2042 |
+
"license": "MIT",
|
| 2043 |
+
"engines": {
|
| 2044 |
+
"node": ">=18"
|
| 2045 |
+
}
|
| 2046 |
+
},
|
| 2047 |
+
"node_modules/picocolors": {
|
| 2048 |
+
"version": "1.1.1",
|
| 2049 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 2050 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
| 2051 |
+
"license": "ISC"
|
| 2052 |
+
},
|
| 2053 |
+
"node_modules/picomatch": {
|
| 2054 |
+
"version": "4.0.7",
|
| 2055 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
| 2056 |
+
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
| 2057 |
+
"license": "MIT",
|
| 2058 |
+
"engines": {
|
| 2059 |
+
"node": ">=12"
|
| 2060 |
+
},
|
| 2061 |
+
"funding": {
|
| 2062 |
+
"url": "https://github.com/sponsors/jonschlinkert"
|
| 2063 |
+
}
|
| 2064 |
+
},
|
| 2065 |
+
"node_modules/postcss": {
|
| 2066 |
+
"version": "8.5.26",
|
| 2067 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
| 2068 |
+
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
| 2069 |
+
"funding": [
|
| 2070 |
+
{
|
| 2071 |
+
"type": "opencollective",
|
| 2072 |
+
"url": "https://opencollective.com/postcss/"
|
| 2073 |
+
},
|
| 2074 |
+
{
|
| 2075 |
+
"type": "tidelift",
|
| 2076 |
+
"url": "https://tidelift.com/funding/github/npm/postcss"
|
| 2077 |
+
},
|
| 2078 |
+
{
|
| 2079 |
+
"type": "github",
|
| 2080 |
+
"url": "https://github.com/sponsors/ai"
|
| 2081 |
+
}
|
| 2082 |
+
],
|
| 2083 |
+
"license": "MIT",
|
| 2084 |
+
"dependencies": {
|
| 2085 |
+
"nanoid": "^3.3.17",
|
| 2086 |
+
"picocolors": "^1.1.1",
|
| 2087 |
+
"source-map-js": "^1.2.1"
|
| 2088 |
+
},
|
| 2089 |
+
"engines": {
|
| 2090 |
+
"node": "^10 || ^12 || >=14"
|
| 2091 |
+
}
|
| 2092 |
+
},
|
| 2093 |
+
"node_modules/react": {
|
| 2094 |
+
"version": "19.2.8",
|
| 2095 |
+
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
| 2096 |
+
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
| 2097 |
+
"license": "MIT",
|
| 2098 |
+
"engines": {
|
| 2099 |
+
"node": ">=0.10.0"
|
| 2100 |
+
}
|
| 2101 |
+
},
|
| 2102 |
+
"node_modules/react-dom": {
|
| 2103 |
+
"version": "19.2.8",
|
| 2104 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
| 2105 |
+
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
| 2106 |
+
"license": "MIT",
|
| 2107 |
+
"dependencies": {
|
| 2108 |
+
"scheduler": "^0.27.0"
|
| 2109 |
+
},
|
| 2110 |
+
"peerDependencies": {
|
| 2111 |
+
"react": "^19.2.8"
|
| 2112 |
+
}
|
| 2113 |
+
},
|
| 2114 |
+
"node_modules/react-refresh": {
|
| 2115 |
+
"version": "0.18.0",
|
| 2116 |
+
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
| 2117 |
+
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
|
| 2118 |
+
"dev": true,
|
| 2119 |
+
"license": "MIT",
|
| 2120 |
+
"engines": {
|
| 2121 |
+
"node": ">=0.10.0"
|
| 2122 |
+
}
|
| 2123 |
+
},
|
| 2124 |
+
"node_modules/rollup": {
|
| 2125 |
+
"version": "4.63.0",
|
| 2126 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz",
|
| 2127 |
+
"integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==",
|
| 2128 |
+
"license": "MIT",
|
| 2129 |
+
"dependencies": {
|
| 2130 |
+
"@types/estree": "1.0.9"
|
| 2131 |
+
},
|
| 2132 |
+
"bin": {
|
| 2133 |
+
"rollup": "dist/bin/rollup"
|
| 2134 |
+
},
|
| 2135 |
+
"engines": {
|
| 2136 |
+
"node": ">=18.0.0",
|
| 2137 |
+
"npm": ">=8.0.0"
|
| 2138 |
+
},
|
| 2139 |
+
"optionalDependencies": {
|
| 2140 |
+
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
|
| 2141 |
+
"@rollup/rollup-android-arm-eabi": "4.63.0",
|
| 2142 |
+
"@rollup/rollup-android-arm64": "4.63.0",
|
| 2143 |
+
"@rollup/rollup-darwin-arm64": "4.63.0",
|
| 2144 |
+
"@rollup/rollup-darwin-x64": "4.63.0",
|
| 2145 |
+
"@rollup/rollup-freebsd-arm64": "4.63.0",
|
| 2146 |
+
"@rollup/rollup-freebsd-x64": "4.63.0",
|
| 2147 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.63.0",
|
| 2148 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.63.0",
|
| 2149 |
+
"@rollup/rollup-linux-arm64-gnu": "4.63.0",
|
| 2150 |
+
"@rollup/rollup-linux-arm64-musl": "4.63.0",
|
| 2151 |
+
"@rollup/rollup-linux-loong64-gnu": "4.63.0",
|
| 2152 |
+
"@rollup/rollup-linux-loong64-musl": "4.63.0",
|
| 2153 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.63.0",
|
| 2154 |
+
"@rollup/rollup-linux-ppc64-musl": "4.63.0",
|
| 2155 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.63.0",
|
| 2156 |
+
"@rollup/rollup-linux-riscv64-musl": "4.63.0",
|
| 2157 |
+
"@rollup/rollup-linux-s390x-gnu": "4.63.0",
|
| 2158 |
+
"@rollup/rollup-linux-x64-gnu": "4.63.0",
|
| 2159 |
+
"@rollup/rollup-linux-x64-musl": "4.63.0",
|
| 2160 |
+
"@rollup/rollup-openbsd-x64": "4.63.0",
|
| 2161 |
+
"@rollup/rollup-openharmony-arm64": "4.63.0",
|
| 2162 |
+
"@rollup/rollup-win32-arm64-msvc": "4.63.0",
|
| 2163 |
+
"@rollup/rollup-win32-ia32-msvc": "4.63.0",
|
| 2164 |
+
"@rollup/rollup-win32-x64-gnu": "4.63.0",
|
| 2165 |
+
"@rollup/rollup-win32-x64-msvc": "4.63.0",
|
| 2166 |
+
"fsevents": "~2.3.2"
|
| 2167 |
+
}
|
| 2168 |
+
},
|
| 2169 |
+
"node_modules/scheduler": {
|
| 2170 |
+
"version": "0.27.0",
|
| 2171 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
| 2172 |
+
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
| 2173 |
+
"license": "MIT"
|
| 2174 |
+
},
|
| 2175 |
+
"node_modules/semver": {
|
| 2176 |
+
"version": "6.3.1",
|
| 2177 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
| 2178 |
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
| 2179 |
+
"dev": true,
|
| 2180 |
+
"license": "ISC",
|
| 2181 |
+
"bin": {
|
| 2182 |
+
"semver": "bin/semver.js"
|
| 2183 |
+
}
|
| 2184 |
+
},
|
| 2185 |
+
"node_modules/source-map-js": {
|
| 2186 |
+
"version": "1.2.1",
|
| 2187 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 2188 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
| 2189 |
+
"license": "BSD-3-Clause",
|
| 2190 |
+
"engines": {
|
| 2191 |
+
"node": ">=0.10.0"
|
| 2192 |
+
}
|
| 2193 |
+
},
|
| 2194 |
+
"node_modules/tailwindcss": {
|
| 2195 |
+
"version": "4.3.3",
|
| 2196 |
+
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
| 2197 |
+
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
| 2198 |
+
"license": "MIT"
|
| 2199 |
+
},
|
| 2200 |
+
"node_modules/tapable": {
|
| 2201 |
+
"version": "2.3.3",
|
| 2202 |
+
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
| 2203 |
+
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
| 2204 |
+
"license": "MIT",
|
| 2205 |
+
"engines": {
|
| 2206 |
+
"node": ">=6"
|
| 2207 |
+
},
|
| 2208 |
+
"funding": {
|
| 2209 |
+
"type": "opencollective",
|
| 2210 |
+
"url": "https://opencollective.com/webpack"
|
| 2211 |
+
}
|
| 2212 |
+
},
|
| 2213 |
+
"node_modules/tinyglobby": {
|
| 2214 |
+
"version": "0.2.17",
|
| 2215 |
+
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
| 2216 |
+
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
| 2217 |
+
"license": "MIT",
|
| 2218 |
+
"dependencies": {
|
| 2219 |
+
"fdir": "^6.5.0",
|
| 2220 |
+
"picomatch": "^4.0.4"
|
| 2221 |
+
},
|
| 2222 |
+
"engines": {
|
| 2223 |
+
"node": ">=12.0.0"
|
| 2224 |
+
},
|
| 2225 |
+
"funding": {
|
| 2226 |
+
"url": "https://github.com/sponsors/SuperchupuDev"
|
| 2227 |
+
}
|
| 2228 |
+
},
|
| 2229 |
+
"node_modules/typescript": {
|
| 2230 |
+
"version": "5.8.3",
|
| 2231 |
+
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
| 2232 |
+
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
| 2233 |
+
"dev": true,
|
| 2234 |
+
"license": "Apache-2.0",
|
| 2235 |
+
"bin": {
|
| 2236 |
+
"tsc": "bin/tsc",
|
| 2237 |
+
"tsserver": "bin/tsserver"
|
| 2238 |
+
},
|
| 2239 |
+
"engines": {
|
| 2240 |
+
"node": ">=14.17"
|
| 2241 |
+
}
|
| 2242 |
+
},
|
| 2243 |
+
"node_modules/undici-types": {
|
| 2244 |
+
"version": "6.21.0",
|
| 2245 |
+
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
| 2246 |
+
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
| 2247 |
+
"devOptional": true,
|
| 2248 |
+
"license": "MIT"
|
| 2249 |
+
},
|
| 2250 |
+
"node_modules/update-browserslist-db": {
|
| 2251 |
+
"version": "1.3.2",
|
| 2252 |
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
|
| 2253 |
+
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
|
| 2254 |
+
"dev": true,
|
| 2255 |
+
"funding": [
|
| 2256 |
+
{
|
| 2257 |
+
"type": "opencollective",
|
| 2258 |
+
"url": "https://opencollective.com/browserslist"
|
| 2259 |
+
},
|
| 2260 |
+
{
|
| 2261 |
+
"type": "tidelift",
|
| 2262 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 2263 |
+
},
|
| 2264 |
+
{
|
| 2265 |
+
"type": "github",
|
| 2266 |
+
"url": "https://github.com/sponsors/ai"
|
| 2267 |
+
}
|
| 2268 |
+
],
|
| 2269 |
+
"license": "MIT",
|
| 2270 |
+
"dependencies": {
|
| 2271 |
+
"escalade": "^3.2.0",
|
| 2272 |
+
"picocolors": "^1.1.1"
|
| 2273 |
+
},
|
| 2274 |
+
"bin": {
|
| 2275 |
+
"update-browserslist-db": "cli.js"
|
| 2276 |
+
},
|
| 2277 |
+
"peerDependencies": {
|
| 2278 |
+
"browserslist": ">= 4.21.0"
|
| 2279 |
+
}
|
| 2280 |
+
},
|
| 2281 |
+
"node_modules/vite": {
|
| 2282 |
+
"version": "7.3.6",
|
| 2283 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
| 2284 |
+
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
| 2285 |
+
"license": "MIT",
|
| 2286 |
+
"dependencies": {
|
| 2287 |
+
"esbuild": "^0.27.0 || ^0.28.0",
|
| 2288 |
+
"fdir": "^6.5.0",
|
| 2289 |
+
"picomatch": "^4.0.3",
|
| 2290 |
+
"postcss": "^8.5.6",
|
| 2291 |
+
"rollup": "^4.43.0",
|
| 2292 |
+
"tinyglobby": "^0.2.15"
|
| 2293 |
+
},
|
| 2294 |
+
"bin": {
|
| 2295 |
+
"vite": "bin/vite.js"
|
| 2296 |
+
},
|
| 2297 |
+
"engines": {
|
| 2298 |
+
"node": "^20.19.0 || >=22.12.0"
|
| 2299 |
+
},
|
| 2300 |
+
"funding": {
|
| 2301 |
+
"url": "https://github.com/vitejs/vite?sponsor=1"
|
| 2302 |
+
},
|
| 2303 |
+
"optionalDependencies": {
|
| 2304 |
+
"fsevents": "~2.3.3"
|
| 2305 |
+
},
|
| 2306 |
+
"peerDependencies": {
|
| 2307 |
+
"@types/node": "^20.19.0 || >=22.12.0",
|
| 2308 |
+
"jiti": ">=1.21.0",
|
| 2309 |
+
"less": "^4.0.0",
|
| 2310 |
+
"lightningcss": "^1.21.0",
|
| 2311 |
+
"sass": "^1.70.0",
|
| 2312 |
+
"sass-embedded": "^1.70.0",
|
| 2313 |
+
"stylus": ">=0.54.8",
|
| 2314 |
+
"sugarss": "^5.0.0",
|
| 2315 |
+
"terser": "^5.16.0",
|
| 2316 |
+
"tsx": "^4.8.1",
|
| 2317 |
+
"yaml": "^2.4.2"
|
| 2318 |
+
},
|
| 2319 |
+
"peerDependenciesMeta": {
|
| 2320 |
+
"@types/node": {
|
| 2321 |
+
"optional": true
|
| 2322 |
+
},
|
| 2323 |
+
"jiti": {
|
| 2324 |
+
"optional": true
|
| 2325 |
+
},
|
| 2326 |
+
"less": {
|
| 2327 |
+
"optional": true
|
| 2328 |
+
},
|
| 2329 |
+
"lightningcss": {
|
| 2330 |
+
"optional": true
|
| 2331 |
+
},
|
| 2332 |
+
"sass": {
|
| 2333 |
+
"optional": true
|
| 2334 |
+
},
|
| 2335 |
+
"sass-embedded": {
|
| 2336 |
+
"optional": true
|
| 2337 |
+
},
|
| 2338 |
+
"stylus": {
|
| 2339 |
+
"optional": true
|
| 2340 |
+
},
|
| 2341 |
+
"sugarss": {
|
| 2342 |
+
"optional": true
|
| 2343 |
+
},
|
| 2344 |
+
"terser": {
|
| 2345 |
+
"optional": true
|
| 2346 |
+
},
|
| 2347 |
+
"tsx": {
|
| 2348 |
+
"optional": true
|
| 2349 |
+
},
|
| 2350 |
+
"yaml": {
|
| 2351 |
+
"optional": true
|
| 2352 |
+
}
|
| 2353 |
+
}
|
| 2354 |
+
},
|
| 2355 |
+
"node_modules/yallist": {
|
| 2356 |
+
"version": "3.1.1",
|
| 2357 |
+
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
| 2358 |
+
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
| 2359 |
+
"dev": true,
|
| 2360 |
+
"license": "ISC"
|
| 2361 |
+
}
|
| 2362 |
+
}
|
| 2363 |
+
}
|
frontend/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"typecheck": "tsc -b"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@tailwindcss/vite": "^4.1.11",
|
| 14 |
+
"react": "^19.1.0",
|
| 15 |
+
"react-dom": "^19.1.0",
|
| 16 |
+
"tailwindcss": "^4.1.11"
|
| 17 |
+
},
|
| 18 |
+
"devDependencies": {
|
| 19 |
+
"@types/node": "^22.15.0",
|
| 20 |
+
"@types/react": "^19.1.0",
|
| 21 |
+
"@types/react-dom": "^19.1.0",
|
| 22 |
+
"@vitejs/plugin-react": "^5.0.0",
|
| 23 |
+
"typescript": "~5.8.3",
|
| 24 |
+
"vite": "^7.1.0"
|
| 25 |
+
}
|
| 26 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from 'react'
|
| 2 |
+
|
| 3 |
+
import { AgentTrace } from './components/AgentTrace'
|
| 4 |
+
import { LatencyBreakdown } from './components/LatencyBreakdown'
|
| 5 |
+
import { useAgentStream } from './lib/useAgentStream'
|
| 6 |
+
|
| 7 |
+
const EXAMPLES = [
|
| 8 |
+
'Analyze Alphabet FY2024 revenue and how it relates to the competitive risks in the 10-K',
|
| 9 |
+
'What was total revenue in FY2024?',
|
| 10 |
+
'How is the company doing?',
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
const SPEEDS = [
|
| 14 |
+
{ label: '1×', value: 1, hint: 'Real latency (~22s)' },
|
| 15 |
+
{ label: '5×', value: 5, hint: 'Compressed' },
|
| 16 |
+
{ label: '20×', value: 20, hint: 'Fast iteration' },
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
export default function App() {
|
| 20 |
+
const { state, send, cancel } = useAgentStream()
|
| 21 |
+
const [draft, setDraft] = useState(EXAMPLES[0])
|
| 22 |
+
const [speed, setSpeed] = useState(5)
|
| 23 |
+
const [agentMode, setAgentMode] = useState<'live' | 'mock'>('mock')
|
| 24 |
+
const answerRef = useRef<HTMLDivElement>(null)
|
| 25 |
+
const streaming = state.status === 'streaming'
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
void fetch('/api/health')
|
| 29 |
+
.then((res) => res.json())
|
| 30 |
+
.then((body: { agent?: string }) => {
|
| 31 |
+
if (body.agent === 'live') setAgentMode('live')
|
| 32 |
+
})
|
| 33 |
+
.catch(() => undefined)
|
| 34 |
+
}, [])
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
answerRef.current?.scrollTo({ top: answerRef.current.scrollHeight })
|
| 38 |
+
}, [state.answer])
|
| 39 |
+
|
| 40 |
+
function submit() {
|
| 41 |
+
const query = draft.trim()
|
| 42 |
+
if (query && !streaming) void send(query, speed)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div className="mx-auto flex h-full max-w-[1400px] flex-col gap-4 p-5">
|
| 47 |
+
<header className="flex flex-wrap items-center gap-3">
|
| 48 |
+
<div>
|
| 49 |
+
<h1 className="text-lg font-semibold tracking-tight text-slate-100">
|
| 50 |
+
FinRAG
|
| 51 |
+
<span className="ml-2 text-sm font-normal text-slate-500">
|
| 52 |
+
Agentic analysis of Alphabet Inc.
|
| 53 |
+
</span>
|
| 54 |
+
</h1>
|
| 55 |
+
</div>
|
| 56 |
+
<div className="ml-auto flex items-center gap-3">
|
| 57 |
+
<span
|
| 58 |
+
className={`rounded-full border px-2.5 py-1 font-mono text-[11px] ${
|
| 59 |
+
agentMode === 'live'
|
| 60 |
+
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
| 61 |
+
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
| 62 |
+
}`}
|
| 63 |
+
>
|
| 64 |
+
{agentMode === 'live' ? 'live · RunPod' : 'mock agent'}
|
| 65 |
+
</span>
|
| 66 |
+
{agentMode === 'mock' && (
|
| 67 |
+
<div className="flex items-center gap-1 rounded-lg border border-edge bg-panel p-1">
|
| 68 |
+
{SPEEDS.map((option) => (
|
| 69 |
+
<button
|
| 70 |
+
key={option.value}
|
| 71 |
+
type="button"
|
| 72 |
+
title={option.hint}
|
| 73 |
+
onClick={() => setSpeed(option.value)}
|
| 74 |
+
className={`rounded px-2 py-1 font-mono text-[11px] transition ${
|
| 75 |
+
speed === option.value
|
| 76 |
+
? 'bg-slate-700 text-slate-100'
|
| 77 |
+
: 'text-slate-500 hover:text-slate-300'
|
| 78 |
+
}`}
|
| 79 |
+
>
|
| 80 |
+
{option.label}
|
| 81 |
+
</button>
|
| 82 |
+
))}
|
| 83 |
+
</div>
|
| 84 |
+
)}
|
| 85 |
+
</div>
|
| 86 |
+
</header>
|
| 87 |
+
|
| 88 |
+
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[1fr_420px]">
|
| 89 |
+
{/* --- Conversation ------------------------------------------------ */}
|
| 90 |
+
<main className="flex min-h-0 flex-col gap-3">
|
| 91 |
+
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-edge bg-panel">
|
| 92 |
+
{state.query && (
|
| 93 |
+
<div className="border-b border-edge px-5 py-3.5">
|
| 94 |
+
<p className="text-sm leading-relaxed text-slate-300">{state.query}</p>
|
| 95 |
+
</div>
|
| 96 |
+
)}
|
| 97 |
+
|
| 98 |
+
<div ref={answerRef} className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
| 99 |
+
{state.status === 'idle' && (
|
| 100 |
+
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
|
| 101 |
+
<p className="max-w-sm text-sm leading-relaxed text-slate-500">
|
| 102 |
+
Ask a question about Alphabet's filings. The supervisor graph plans, calls
|
| 103 |
+
tools, audits its own output, and replans when the audit fails.
|
| 104 |
+
</p>
|
| 105 |
+
<div className="flex flex-col gap-1.5">
|
| 106 |
+
{EXAMPLES.map((example) => (
|
| 107 |
+
<button
|
| 108 |
+
key={example}
|
| 109 |
+
type="button"
|
| 110 |
+
onClick={() => setDraft(example)}
|
| 111 |
+
className="max-w-md rounded-lg border border-edge px-3 py-2 text-left text-xs text-slate-400 transition hover:border-slate-600 hover:text-slate-200"
|
| 112 |
+
>
|
| 113 |
+
{example}
|
| 114 |
+
</button>
|
| 115 |
+
))}
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
)}
|
| 119 |
+
|
| 120 |
+
{state.clarification && (
|
| 121 |
+
<div className="rounded-lg border border-sky-500/40 bg-sky-500/10 p-4">
|
| 122 |
+
<p className="font-mono text-[10px] uppercase tracking-wider text-sky-300">
|
| 123 |
+
Gatekeeper needs clarification
|
| 124 |
+
</p>
|
| 125 |
+
<p className="mt-2 text-sm leading-relaxed text-slate-200">
|
| 126 |
+
{state.clarification}
|
| 127 |
+
</p>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
|
| 131 |
+
{state.answer && (
|
| 132 |
+
<div className="space-y-4">
|
| 133 |
+
{state.answer.split('\n\n').map((para, i) => (
|
| 134 |
+
<p key={i} className="text-sm leading-relaxed text-slate-200">
|
| 135 |
+
{para}
|
| 136 |
+
{streaming && i === state.answer.split('\n\n').length - 1 && (
|
| 137 |
+
<span className="ml-0.5 inline-block h-4 w-1.5 translate-y-0.5 animate-pulse bg-emerald-400" />
|
| 138 |
+
)}
|
| 139 |
+
</p>
|
| 140 |
+
))}
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
|
| 144 |
+
{streaming && !state.answer && !state.clarification && (
|
| 145 |
+
<p className="text-xs text-slate-500">
|
| 146 |
+
Working — first question after idle can take a few minutes while
|
| 147 |
+
the GPU worker starts. Watch the trace panel.
|
| 148 |
+
</p>
|
| 149 |
+
)}
|
| 150 |
+
|
| 151 |
+
{state.errorMessage && (
|
| 152 |
+
<div className="rounded-lg border border-rose-500/40 bg-rose-500/10 p-4">
|
| 153 |
+
<p className="font-mono text-[10px] uppercase tracking-wider text-rose-300">
|
| 154 |
+
Stream failed
|
| 155 |
+
</p>
|
| 156 |
+
<p className="mt-2 text-sm text-slate-300">{state.errorMessage}</p>
|
| 157 |
+
</div>
|
| 158 |
+
)}
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{/* --- Composer -------------------------------------------------- */}
|
| 163 |
+
<div className="rounded-xl border border-edge bg-panel p-2.5">
|
| 164 |
+
<textarea
|
| 165 |
+
value={draft}
|
| 166 |
+
onChange={(event) => setDraft(event.target.value)}
|
| 167 |
+
onKeyDown={(event) => {
|
| 168 |
+
if (event.key === 'Enter' && !event.shiftKey) {
|
| 169 |
+
event.preventDefault()
|
| 170 |
+
submit()
|
| 171 |
+
}
|
| 172 |
+
}}
|
| 173 |
+
rows={2}
|
| 174 |
+
placeholder="Ask about revenue, margins, risk factors…"
|
| 175 |
+
className="w-full resize-none bg-transparent px-2 py-1.5 text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none"
|
| 176 |
+
/>
|
| 177 |
+
<div className="flex items-center gap-2 px-1">
|
| 178 |
+
<span className="font-mono text-[10px] text-slate-600">
|
| 179 |
+
Enter to send · Shift+Enter for newline
|
| 180 |
+
</span>
|
| 181 |
+
{streaming ? (
|
| 182 |
+
<button
|
| 183 |
+
type="button"
|
| 184 |
+
onClick={cancel}
|
| 185 |
+
className="ml-auto rounded-lg border border-edge px-3.5 py-1.5 text-xs font-medium text-slate-300 transition hover:border-rose-500/50 hover:text-rose-300"
|
| 186 |
+
>
|
| 187 |
+
Stop
|
| 188 |
+
</button>
|
| 189 |
+
) : (
|
| 190 |
+
<button
|
| 191 |
+
type="button"
|
| 192 |
+
onClick={submit}
|
| 193 |
+
disabled={!draft.trim()}
|
| 194 |
+
className="ml-auto rounded-lg bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-40"
|
| 195 |
+
>
|
| 196 |
+
Send
|
| 197 |
+
</button>
|
| 198 |
+
)}
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</main>
|
| 202 |
+
|
| 203 |
+
{/* --- Trace panel -------------------------------------------------- */}
|
| 204 |
+
<aside className="flex min-h-0 flex-col gap-3">
|
| 205 |
+
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-edge bg-panel">
|
| 206 |
+
<header className="flex items-center gap-2 border-b border-edge px-4 py-3">
|
| 207 |
+
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
|
| 208 |
+
Agent trace
|
| 209 |
+
</h2>
|
| 210 |
+
{streaming && (
|
| 211 |
+
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
|
| 212 |
+
)}
|
| 213 |
+
{state.runId && (
|
| 214 |
+
<span className="ml-auto font-mono text-[10px] text-slate-600">{state.runId}</span>
|
| 215 |
+
)}
|
| 216 |
+
</header>
|
| 217 |
+
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
| 218 |
+
<AgentTrace trace={state.trace} />
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
{state.summary && <LatencyBreakdown summary={state.summary} />}
|
| 222 |
+
</aside>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
)
|
| 226 |
+
}
|
frontend/src/assets/hero.png
ADDED
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/assets/vite.svg
ADDED
|
|
frontend/src/components/AgentTrace.tsx
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NodeName } from '../lib/events'
|
| 2 |
+
import { NODE_LABELS, TOOL_LABELS } from '../lib/events'
|
| 3 |
+
import type { TraceEntry } from '../lib/useAgentStream'
|
| 4 |
+
|
| 5 |
+
const NODE_ACCENT: Record<NodeName, string> = {
|
| 6 |
+
gatekeeper: 'text-sky-300 border-sky-400/30 bg-sky-400/10',
|
| 7 |
+
planner: 'text-violet-300 border-violet-400/30 bg-violet-400/10',
|
| 8 |
+
tool_executor: 'text-amber-300 border-amber-400/30 bg-amber-400/10',
|
| 9 |
+
auditor: 'text-rose-300 border-rose-400/30 bg-rose-400/10',
|
| 10 |
+
synthesizer: 'text-emerald-300 border-emerald-400/30 bg-emerald-400/10',
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function formatMs(ms?: number) {
|
| 14 |
+
if (ms === undefined) return null
|
| 15 |
+
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function Spinner() {
|
| 19 |
+
return (
|
| 20 |
+
<span className="inline-block size-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
| 21 |
+
)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function ToolRow({ tool }: { tool: TraceEntry['tools'][number] }) {
|
| 25 |
+
return (
|
| 26 |
+
<li className="animate-trace-in rounded-lg border border-edge/70 bg-canvas/60 p-2.5">
|
| 27 |
+
<div className="flex items-center gap-2">
|
| 28 |
+
{tool.status === 'running' ? (
|
| 29 |
+
<span className="text-amber-300">
|
| 30 |
+
<Spinner />
|
| 31 |
+
</span>
|
| 32 |
+
) : tool.status === 'failed' ? (
|
| 33 |
+
<span className="text-rose-400">✕</span>
|
| 34 |
+
) : (
|
| 35 |
+
<span className="text-emerald-400">✓</span>
|
| 36 |
+
)}
|
| 37 |
+
<span className="font-mono text-xs font-medium text-amber-200">
|
| 38 |
+
{TOOL_LABELS[tool.toolName] ?? tool.toolName}
|
| 39 |
+
</span>
|
| 40 |
+
<span className="ml-auto font-mono text-[11px] text-slate-500">
|
| 41 |
+
{formatMs(tool.durationMs)}
|
| 42 |
+
{tool.resultCount != null && tool.resultCount > 0 && (
|
| 43 |
+
<span className="ml-1.5">· {tool.resultCount} hits</span>
|
| 44 |
+
)}
|
| 45 |
+
</span>
|
| 46 |
+
</div>
|
| 47 |
+
<p className="mt-1.5 truncate font-mono text-[11px] text-slate-500" title={tool.toolInput}>
|
| 48 |
+
“{tool.toolInput}”
|
| 49 |
+
</p>
|
| 50 |
+
{tool.preview && (
|
| 51 |
+
<p className="mt-1.5 line-clamp-2 text-[11px] leading-relaxed text-slate-400">
|
| 52 |
+
{tool.preview}
|
| 53 |
+
</p>
|
| 54 |
+
)}
|
| 55 |
+
{tool.error && <p className="mt-1.5 text-[11px] text-rose-400">{tool.error}</p>}
|
| 56 |
+
</li>
|
| 57 |
+
)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function AuditVerdict({ audit }: { audit: NonNullable<TraceEntry['audit']> }) {
|
| 61 |
+
const replan = audit.action === 'replan'
|
| 62 |
+
return (
|
| 63 |
+
<div
|
| 64 |
+
className={`animate-trace-in mt-2 rounded-lg border p-2.5 ${
|
| 65 |
+
replan ? 'border-rose-500/40 bg-rose-500/10' : 'border-emerald-500/40 bg-emerald-500/10'
|
| 66 |
+
}`}
|
| 67 |
+
>
|
| 68 |
+
<div className="flex items-center gap-2">
|
| 69 |
+
<span className="font-mono text-[11px] tracking-wide text-slate-300">
|
| 70 |
+
confidence
|
| 71 |
+
</span>
|
| 72 |
+
<span className="flex gap-0.5">
|
| 73 |
+
{[1, 2, 3, 4, 5].map((n) => (
|
| 74 |
+
<span
|
| 75 |
+
key={n}
|
| 76 |
+
className={`h-3.5 w-1.5 rounded-sm ${
|
| 77 |
+
n <= audit.confidenceScore
|
| 78 |
+
? replan
|
| 79 |
+
? 'bg-rose-400'
|
| 80 |
+
: 'bg-emerald-400'
|
| 81 |
+
: 'bg-slate-700'
|
| 82 |
+
}`}
|
| 83 |
+
/>
|
| 84 |
+
))}
|
| 85 |
+
</span>
|
| 86 |
+
<span className="font-mono text-[11px] text-slate-400">{audit.confidenceScore}/5</span>
|
| 87 |
+
<span
|
| 88 |
+
className={`ml-auto rounded px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-wider ${
|
| 89 |
+
replan ? 'bg-rose-500/20 text-rose-300' : 'bg-emerald-500/20 text-emerald-300'
|
| 90 |
+
}`}
|
| 91 |
+
>
|
| 92 |
+
{replan ? 'replan' : 'accept'}
|
| 93 |
+
</span>
|
| 94 |
+
</div>
|
| 95 |
+
<p className="mt-2 text-[11px] leading-relaxed text-slate-400">{audit.reasoning}</p>
|
| 96 |
+
</div>
|
| 97 |
+
)
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function TraceCard({ entry, isLast }: { entry: TraceEntry; isLast: boolean }) {
|
| 101 |
+
const running = entry.status === 'running'
|
| 102 |
+
return (
|
| 103 |
+
<li className="animate-trace-in relative pl-8">
|
| 104 |
+
{/* Connector down to the next node. */}
|
| 105 |
+
{!isLast && <span className="absolute left-[11px] top-6 h-[calc(100%-0.5rem)] w-px bg-edge" />}
|
| 106 |
+
<span
|
| 107 |
+
className={`absolute left-0 top-1 flex size-[23px] items-center justify-center rounded-full border text-[10px] ${
|
| 108 |
+
NODE_ACCENT[entry.node]
|
| 109 |
+
}`}
|
| 110 |
+
>
|
| 111 |
+
{running ? <Spinner /> : '✓'}
|
| 112 |
+
</span>
|
| 113 |
+
|
| 114 |
+
<div className="flex items-baseline gap-2">
|
| 115 |
+
<h3 className="text-sm font-semibold text-slate-200">{NODE_LABELS[entry.node]}</h3>
|
| 116 |
+
{entry.attempt > 1 && (
|
| 117 |
+
<span className="rounded bg-rose-500/15 px-1.5 py-0.5 font-mono text-[10px] font-semibold text-rose-300">
|
| 118 |
+
attempt {entry.attempt}
|
| 119 |
+
</span>
|
| 120 |
+
)}
|
| 121 |
+
<span className="ml-auto font-mono text-[11px] text-slate-500">
|
| 122 |
+
{running ? 'running…' : formatMs(entry.durationMs)}
|
| 123 |
+
</span>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{entry.plan && (
|
| 127 |
+
<ol className="mt-2 space-y-1">
|
| 128 |
+
{entry.plan.map((step, i) => (
|
| 129 |
+
<li key={`${step.tool_name}-${i}`} className="flex items-center gap-2 text-[11px]">
|
| 130 |
+
<span className="font-mono text-slate-600">{i + 1}.</span>
|
| 131 |
+
<span
|
| 132 |
+
className={`font-mono ${
|
| 133 |
+
step.tool_name === 'FINISH' ? 'text-slate-500' : 'text-violet-300'
|
| 134 |
+
}`}
|
| 135 |
+
>
|
| 136 |
+
{TOOL_LABELS[step.tool_name] ?? step.tool_name}
|
| 137 |
+
</span>
|
| 138 |
+
{step.tool_input && (
|
| 139 |
+
<span className="truncate text-slate-500" title={step.tool_input}>
|
| 140 |
+
{step.tool_input}
|
| 141 |
+
</span>
|
| 142 |
+
)}
|
| 143 |
+
</li>
|
| 144 |
+
))}
|
| 145 |
+
</ol>
|
| 146 |
+
)}
|
| 147 |
+
|
| 148 |
+
{entry.tools.length > 0 && (
|
| 149 |
+
<ul className="mt-2 space-y-1.5">
|
| 150 |
+
{entry.tools.map((tool, i) => (
|
| 151 |
+
<ToolRow key={`${tool.toolName}-${i}`} tool={tool} />
|
| 152 |
+
))}
|
| 153 |
+
</ul>
|
| 154 |
+
)}
|
| 155 |
+
|
| 156 |
+
{entry.audit && <AuditVerdict audit={entry.audit} />}
|
| 157 |
+
</li>
|
| 158 |
+
)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
export function AgentTrace({ trace }: { trace: TraceEntry[] }) {
|
| 162 |
+
if (trace.length === 0) {
|
| 163 |
+
return (
|
| 164 |
+
<p className="px-1 text-xs leading-relaxed text-slate-500">
|
| 165 |
+
Node transitions appear here as the supervisor graph runs — plans, tool calls, and the
|
| 166 |
+
Auditor's verdict on each one.
|
| 167 |
+
</p>
|
| 168 |
+
)
|
| 169 |
+
}
|
| 170 |
+
return (
|
| 171 |
+
<ol className="space-y-5">
|
| 172 |
+
{trace.map((entry, i) => (
|
| 173 |
+
<TraceCard key={entry.id} entry={entry} isLast={i === trace.length - 1} />
|
| 174 |
+
))}
|
| 175 |
+
</ol>
|
| 176 |
+
)
|
| 177 |
+
}
|
frontend/src/components/LatencyBreakdown.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NodeName, RunEndEvent } from '../lib/events'
|
| 2 |
+
import { NODE_LABELS, NODE_ORDER } from '../lib/events'
|
| 3 |
+
|
| 4 |
+
const NODE_BAR: Record<NodeName, string> = {
|
| 5 |
+
gatekeeper: 'bg-sky-400',
|
| 6 |
+
planner: 'bg-violet-400',
|
| 7 |
+
tool_executor: 'bg-amber-400',
|
| 8 |
+
auditor: 'bg-rose-400',
|
| 9 |
+
synthesizer: 'bg-emerald-400',
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Per-node wall time for the run. The point is to make the expensive node
|
| 14 |
+
* obvious at a glance — "the Auditor is 40% of latency" is only actionable if
|
| 15 |
+
* you can see it without reading logs.
|
| 16 |
+
*/
|
| 17 |
+
export function LatencyBreakdown({ summary }: { summary: RunEndEvent }) {
|
| 18 |
+
const total = summary.duration_ms || 1
|
| 19 |
+
const nodes = NODE_ORDER.filter((node) => (summary.node_timings_ms[node] ?? 0) > 0)
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<section className="rounded-xl border border-edge bg-panel p-4">
|
| 23 |
+
<header className="flex items-baseline gap-2">
|
| 24 |
+
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
|
| 25 |
+
Latency breakdown
|
| 26 |
+
</h3>
|
| 27 |
+
<span className="ml-auto font-mono text-sm font-semibold text-slate-200">
|
| 28 |
+
{(summary.duration_ms / 1000).toFixed(1)}s
|
| 29 |
+
</span>
|
| 30 |
+
</header>
|
| 31 |
+
|
| 32 |
+
<div className="mt-3 flex h-2 overflow-hidden rounded-full bg-canvas">
|
| 33 |
+
{nodes.map((node) => (
|
| 34 |
+
<div
|
| 35 |
+
key={node}
|
| 36 |
+
className={NODE_BAR[node]}
|
| 37 |
+
style={{ width: `${((summary.node_timings_ms[node] ?? 0) / total) * 100}%` }}
|
| 38 |
+
title={`${NODE_LABELS[node]}: ${summary.node_timings_ms[node]}ms`}
|
| 39 |
+
/>
|
| 40 |
+
))}
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<ul className="mt-3 space-y-1.5">
|
| 44 |
+
{nodes
|
| 45 |
+
.slice()
|
| 46 |
+
.sort((a, b) => (summary.node_timings_ms[b] ?? 0) - (summary.node_timings_ms[a] ?? 0))
|
| 47 |
+
.map((node) => {
|
| 48 |
+
const ms = summary.node_timings_ms[node] ?? 0
|
| 49 |
+
return (
|
| 50 |
+
<li key={node} className="flex items-center gap-2 text-xs">
|
| 51 |
+
<span className={`size-2 rounded-sm ${NODE_BAR[node]}`} />
|
| 52 |
+
<span className="text-slate-400">{NODE_LABELS[node]}</span>
|
| 53 |
+
<span className="ml-auto font-mono text-slate-300">
|
| 54 |
+
{(ms / 1000).toFixed(1)}s
|
| 55 |
+
</span>
|
| 56 |
+
<span className="w-10 text-right font-mono text-slate-500">
|
| 57 |
+
{Math.round((ms / total) * 100)}%
|
| 58 |
+
</span>
|
| 59 |
+
</li>
|
| 60 |
+
)
|
| 61 |
+
})}
|
| 62 |
+
</ul>
|
| 63 |
+
|
| 64 |
+
<dl className="mt-4 grid grid-cols-3 gap-2 border-t border-edge pt-3">
|
| 65 |
+
{[
|
| 66 |
+
['LLM calls', summary.llm_calls],
|
| 67 |
+
['Out tokens', summary.output_tokens],
|
| 68 |
+
['Replans', summary.replans],
|
| 69 |
+
].map(([label, value]) => (
|
| 70 |
+
<div key={label as string}>
|
| 71 |
+
<dt className="text-[10px] uppercase tracking-wider text-slate-500">{label}</dt>
|
| 72 |
+
<dd className="font-mono text-sm text-slate-200">{value}</dd>
|
| 73 |
+
</div>
|
| 74 |
+
))}
|
| 75 |
+
</dl>
|
| 76 |
+
</section>
|
| 77 |
+
)
|
| 78 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import 'tailwindcss';
|
| 2 |
+
|
| 3 |
+
@theme {
|
| 4 |
+
--color-canvas: #0a0d14;
|
| 5 |
+
--color-panel: #11151f;
|
| 6 |
+
--color-edge: #202634;
|
| 7 |
+
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
@keyframes trace-in {
|
| 11 |
+
from {
|
| 12 |
+
opacity: 0;
|
| 13 |
+
transform: translateY(6px);
|
| 14 |
+
}
|
| 15 |
+
to {
|
| 16 |
+
opacity: 1;
|
| 17 |
+
transform: translateY(0);
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@utility animate-trace-in {
|
| 22 |
+
animation: trace-in 0.28s ease-out both;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
html,
|
| 26 |
+
body,
|
| 27 |
+
#root {
|
| 28 |
+
height: 100%;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
body {
|
| 32 |
+
background-color: var(--color-canvas);
|
| 33 |
+
color: #e6e9ef;
|
| 34 |
+
-webkit-font-smoothing: antialiased;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* Scrollbars that don't fight the dark theme. */
|
| 38 |
+
* {
|
| 39 |
+
scrollbar-width: thin;
|
| 40 |
+
scrollbar-color: #2a3040 transparent;
|
| 41 |
+
}
|
| 42 |
+
*::-webkit-scrollbar {
|
| 43 |
+
width: 8px;
|
| 44 |
+
height: 8px;
|
| 45 |
+
}
|
| 46 |
+
*::-webkit-scrollbar-thumb {
|
| 47 |
+
background: #2a3040;
|
| 48 |
+
border-radius: 4px;
|
| 49 |
+
}
|
frontend/src/lib/events.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Mirror of `backend/app/events.py`. Keep the two in step.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
export type NodeName =
|
| 6 |
+
| 'gatekeeper'
|
| 7 |
+
| 'planner'
|
| 8 |
+
| 'tool_executor'
|
| 9 |
+
| 'auditor'
|
| 10 |
+
| 'synthesizer'
|
| 11 |
+
|
| 12 |
+
export const NODE_ORDER: NodeName[] = [
|
| 13 |
+
'gatekeeper',
|
| 14 |
+
'planner',
|
| 15 |
+
'tool_executor',
|
| 16 |
+
'auditor',
|
| 17 |
+
'synthesizer',
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
export const NODE_LABELS: Record<NodeName, string> = {
|
| 21 |
+
gatekeeper: 'Gatekeeper',
|
| 22 |
+
planner: 'Planner',
|
| 23 |
+
tool_executor: 'Tool Executor',
|
| 24 |
+
auditor: 'Auditor',
|
| 25 |
+
synthesizer: 'Synthesizer',
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export const TOOL_LABELS: Record<string, string> = {
|
| 29 |
+
librarian_rag_tool: 'Librarian',
|
| 30 |
+
analyst_sql_tool: 'SQL Analyst',
|
| 31 |
+
analyst_trend_tool: 'Trend Analyst',
|
| 32 |
+
scout_web_search_tool: 'Scout',
|
| 33 |
+
FINISH: 'Finish',
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
interface BaseEvent {
|
| 37 |
+
seq: number
|
| 38 |
+
ts: number
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export interface PlanStep {
|
| 42 |
+
tool_name: string
|
| 43 |
+
tool_input: string
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export interface RunStartEvent extends BaseEvent {
|
| 47 |
+
type: 'run_start'
|
| 48 |
+
run_id: string
|
| 49 |
+
conversation_id: string
|
| 50 |
+
query: string
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export interface NodeStartEvent extends BaseEvent {
|
| 54 |
+
type: 'node_start'
|
| 55 |
+
node: NodeName
|
| 56 |
+
attempt: number
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export interface NodeEndEvent extends BaseEvent {
|
| 60 |
+
type: 'node_end'
|
| 61 |
+
node: NodeName
|
| 62 |
+
attempt: number
|
| 63 |
+
duration_ms: number
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export interface PlanEvent extends BaseEvent {
|
| 67 |
+
type: 'plan'
|
| 68 |
+
attempt: number
|
| 69 |
+
steps: PlanStep[]
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export interface ToolStartEvent extends BaseEvent {
|
| 73 |
+
type: 'tool_start'
|
| 74 |
+
tool_name: string
|
| 75 |
+
tool_input: string
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export interface ToolEndEvent extends BaseEvent {
|
| 79 |
+
type: 'tool_end'
|
| 80 |
+
tool_name: string
|
| 81 |
+
duration_ms: number
|
| 82 |
+
ok: boolean
|
| 83 |
+
preview: string | null
|
| 84 |
+
result_count: number | null
|
| 85 |
+
error: string | null
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export interface AuditEvent extends BaseEvent {
|
| 89 |
+
type: 'audit'
|
| 90 |
+
attempt: number
|
| 91 |
+
confidence_score: number
|
| 92 |
+
is_relevant: boolean
|
| 93 |
+
reasoning: string
|
| 94 |
+
action: 'accept' | 'replan'
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
export interface ClarificationEvent extends BaseEvent {
|
| 98 |
+
type: 'clarification'
|
| 99 |
+
question: string
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export interface TokenEvent extends BaseEvent {
|
| 103 |
+
type: 'token'
|
| 104 |
+
text: string
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export interface RunEndEvent extends BaseEvent {
|
| 108 |
+
type: 'run_end'
|
| 109 |
+
run_id: string
|
| 110 |
+
duration_ms: number
|
| 111 |
+
node_timings_ms: Record<string, number>
|
| 112 |
+
llm_calls: number
|
| 113 |
+
output_tokens: number
|
| 114 |
+
replans: number
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
export interface ErrorEvent extends BaseEvent {
|
| 118 |
+
type: 'error'
|
| 119 |
+
code: string
|
| 120 |
+
message: string
|
| 121 |
+
retryable: boolean
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
export interface HeartbeatEvent extends BaseEvent {
|
| 125 |
+
type: 'heartbeat'
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export type AgentEvent =
|
| 129 |
+
| RunStartEvent
|
| 130 |
+
| NodeStartEvent
|
| 131 |
+
| NodeEndEvent
|
| 132 |
+
| PlanEvent
|
| 133 |
+
| ToolStartEvent
|
| 134 |
+
| ToolEndEvent
|
| 135 |
+
| AuditEvent
|
| 136 |
+
| ClarificationEvent
|
| 137 |
+
| TokenEvent
|
| 138 |
+
| RunEndEvent
|
| 139 |
+
| ErrorEvent
|
| 140 |
+
| HeartbeatEvent
|
frontend/src/lib/sse.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Incremental SSE frame parser.
|
| 3 |
+
*
|
| 4 |
+
* Needed because we read the stream with `fetch` + `ReadableStream` instead of
|
| 5 |
+
* `EventSource`. `EventSource` would parse frames for us, but it only issues GET
|
| 6 |
+
* requests and cannot set headers — so it can carry neither the query body nor
|
| 7 |
+
* the Authorization header the API will require. Parsing by hand is the price.
|
| 8 |
+
*
|
| 9 |
+
* Network chunks do not align with frame boundaries, so state has to persist
|
| 10 |
+
* across calls to `push`.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
export interface SSEFrame {
|
| 14 |
+
event?: string
|
| 15 |
+
data: string
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function createSSEParser() {
|
| 19 |
+
let buffer = ''
|
| 20 |
+
|
| 21 |
+
return function push(chunk: string): SSEFrame[] {
|
| 22 |
+
buffer += chunk
|
| 23 |
+
|
| 24 |
+
// A trailing CR may be the first half of a CRLF split across two chunks.
|
| 25 |
+
// Normalising it now would fabricate a frame boundary, so hold it back.
|
| 26 |
+
let held = ''
|
| 27 |
+
let work = buffer
|
| 28 |
+
if (work.endsWith('\r')) {
|
| 29 |
+
held = '\r'
|
| 30 |
+
work = work.slice(0, -1)
|
| 31 |
+
}
|
| 32 |
+
work = work.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
| 33 |
+
|
| 34 |
+
const parts = work.split('\n\n')
|
| 35 |
+
buffer = (parts.pop() ?? '') + held
|
| 36 |
+
|
| 37 |
+
const frames: SSEFrame[] = []
|
| 38 |
+
for (const part of parts) {
|
| 39 |
+
if (!part.trim()) continue
|
| 40 |
+
let event: string | undefined
|
| 41 |
+
const data: string[] = []
|
| 42 |
+
for (const line of part.split('\n')) {
|
| 43 |
+
if (!line || line.startsWith(':')) continue // comment / heartbeat
|
| 44 |
+
const colon = line.indexOf(':')
|
| 45 |
+
const field = colon === -1 ? line : line.slice(0, colon)
|
| 46 |
+
let value = colon === -1 ? '' : line.slice(colon + 1)
|
| 47 |
+
if (value.startsWith(' ')) value = value.slice(1)
|
| 48 |
+
if (field === 'event') event = value
|
| 49 |
+
else if (field === 'data') data.push(value)
|
| 50 |
+
}
|
| 51 |
+
if (data.length) frames.push({ event, data: data.join('\n') })
|
| 52 |
+
}
|
| 53 |
+
return frames
|
| 54 |
+
}
|
| 55 |
+
}
|
frontend/src/lib/useAgentStream.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useReducer, useRef } from 'react'
|
| 2 |
+
|
| 3 |
+
import type {
|
| 4 |
+
AgentEvent,
|
| 5 |
+
NodeName,
|
| 6 |
+
PlanStep,
|
| 7 |
+
RunEndEvent,
|
| 8 |
+
} from './events'
|
| 9 |
+
import { createSSEParser } from './sse'
|
| 10 |
+
|
| 11 |
+
export interface ToolCall {
|
| 12 |
+
toolName: string
|
| 13 |
+
toolInput: string
|
| 14 |
+
status: 'running' | 'done' | 'failed'
|
| 15 |
+
durationMs?: number
|
| 16 |
+
preview?: string | null
|
| 17 |
+
resultCount?: number | null
|
| 18 |
+
error?: string | null
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/** One visit to a node. A node re-entered after a replan gets its own entry. */
|
| 22 |
+
export interface TraceEntry {
|
| 23 |
+
id: string
|
| 24 |
+
node: NodeName
|
| 25 |
+
attempt: number
|
| 26 |
+
status: 'running' | 'done'
|
| 27 |
+
durationMs?: number
|
| 28 |
+
plan?: PlanStep[]
|
| 29 |
+
audit?: {
|
| 30 |
+
confidenceScore: number
|
| 31 |
+
isRelevant: boolean
|
| 32 |
+
reasoning: string
|
| 33 |
+
action: 'accept' | 'replan'
|
| 34 |
+
}
|
| 35 |
+
tools: ToolCall[]
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export interface RunState {
|
| 39 |
+
status: 'idle' | 'streaming' | 'done' | 'error'
|
| 40 |
+
query: string
|
| 41 |
+
runId?: string
|
| 42 |
+
trace: TraceEntry[]
|
| 43 |
+
answer: string
|
| 44 |
+
clarification?: string
|
| 45 |
+
errorMessage?: string
|
| 46 |
+
summary?: RunEndEvent
|
| 47 |
+
lastSeq: number
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const initialState: RunState = {
|
| 51 |
+
status: 'idle',
|
| 52 |
+
query: '',
|
| 53 |
+
trace: [],
|
| 54 |
+
answer: '',
|
| 55 |
+
lastSeq: 0,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
type Action =
|
| 59 |
+
| { kind: 'reset'; query: string }
|
| 60 |
+
| { kind: 'event'; event: AgentEvent }
|
| 61 |
+
| { kind: 'closed' }
|
| 62 |
+
| { kind: 'error'; message: string }
|
| 63 |
+
|
| 64 |
+
/** Mutate the most recent trace entry for `node`, returning a new array. */
|
| 65 |
+
function patchLatest(
|
| 66 |
+
trace: TraceEntry[],
|
| 67 |
+
node: NodeName,
|
| 68 |
+
patch: (entry: TraceEntry) => TraceEntry,
|
| 69 |
+
): TraceEntry[] {
|
| 70 |
+
for (let i = trace.length - 1; i >= 0; i -= 1) {
|
| 71 |
+
if (trace[i].node === node) {
|
| 72 |
+
const next = trace.slice()
|
| 73 |
+
next[i] = patch(trace[i])
|
| 74 |
+
return next
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
return trace
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function reducer(state: RunState, action: Action): RunState {
|
| 81 |
+
switch (action.kind) {
|
| 82 |
+
case 'reset':
|
| 83 |
+
return { ...initialState, status: 'streaming', query: action.query }
|
| 84 |
+
|
| 85 |
+
case 'closed':
|
| 86 |
+
return state.status === 'streaming' ? { ...state, status: 'done' } : state
|
| 87 |
+
|
| 88 |
+
case 'error':
|
| 89 |
+
return {
|
| 90 |
+
...state,
|
| 91 |
+
status: 'error',
|
| 92 |
+
errorMessage: action.message,
|
| 93 |
+
trace: state.trace.map((entry) =>
|
| 94 |
+
entry.status === 'running' ? { ...entry, status: 'done' } : entry,
|
| 95 |
+
),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
case 'event': {
|
| 99 |
+
const event = action.event
|
| 100 |
+
// Duplicates and replays are dropped rather than applied twice; an
|
| 101 |
+
// out-of-order node transition would render the graph walk wrong.
|
| 102 |
+
if (event.seq <= state.lastSeq) return state
|
| 103 |
+
const base = { ...state, lastSeq: event.seq }
|
| 104 |
+
|
| 105 |
+
switch (event.type) {
|
| 106 |
+
case 'run_start':
|
| 107 |
+
return { ...base, runId: event.run_id }
|
| 108 |
+
|
| 109 |
+
case 'node_start':
|
| 110 |
+
return {
|
| 111 |
+
...base,
|
| 112 |
+
trace: [
|
| 113 |
+
...base.trace,
|
| 114 |
+
{
|
| 115 |
+
id: `${event.node}-${event.attempt}-${event.seq}`,
|
| 116 |
+
node: event.node,
|
| 117 |
+
attempt: event.attempt,
|
| 118 |
+
status: 'running',
|
| 119 |
+
tools: [],
|
| 120 |
+
},
|
| 121 |
+
],
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
case 'node_end':
|
| 125 |
+
return {
|
| 126 |
+
...base,
|
| 127 |
+
trace: patchLatest(base.trace, event.node, (entry) => ({
|
| 128 |
+
...entry,
|
| 129 |
+
status: 'done',
|
| 130 |
+
durationMs: event.duration_ms,
|
| 131 |
+
})),
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
case 'plan':
|
| 135 |
+
return {
|
| 136 |
+
...base,
|
| 137 |
+
trace: patchLatest(base.trace, 'planner', (entry) => ({
|
| 138 |
+
...entry,
|
| 139 |
+
plan: event.steps,
|
| 140 |
+
})),
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
case 'tool_start':
|
| 144 |
+
return {
|
| 145 |
+
...base,
|
| 146 |
+
trace: patchLatest(base.trace, 'tool_executor', (entry) => ({
|
| 147 |
+
...entry,
|
| 148 |
+
tools: [
|
| 149 |
+
...entry.tools,
|
| 150 |
+
{
|
| 151 |
+
toolName: event.tool_name,
|
| 152 |
+
toolInput: event.tool_input,
|
| 153 |
+
status: 'running',
|
| 154 |
+
},
|
| 155 |
+
],
|
| 156 |
+
})),
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
case 'tool_end':
|
| 160 |
+
return {
|
| 161 |
+
...base,
|
| 162 |
+
trace: patchLatest(base.trace, 'tool_executor', (entry) => {
|
| 163 |
+
const tools = entry.tools.slice()
|
| 164 |
+
for (let i = tools.length - 1; i >= 0; i -= 1) {
|
| 165 |
+
if (tools[i].toolName === event.tool_name && tools[i].status === 'running') {
|
| 166 |
+
tools[i] = {
|
| 167 |
+
...tools[i],
|
| 168 |
+
status: event.ok ? 'done' : 'failed',
|
| 169 |
+
durationMs: event.duration_ms,
|
| 170 |
+
preview: event.preview,
|
| 171 |
+
resultCount: event.result_count,
|
| 172 |
+
error: event.error,
|
| 173 |
+
}
|
| 174 |
+
break
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
return { ...entry, tools }
|
| 178 |
+
}),
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
case 'audit':
|
| 182 |
+
return {
|
| 183 |
+
...base,
|
| 184 |
+
trace: patchLatest(base.trace, 'auditor', (entry) => ({
|
| 185 |
+
...entry,
|
| 186 |
+
audit: {
|
| 187 |
+
confidenceScore: event.confidence_score,
|
| 188 |
+
isRelevant: event.is_relevant,
|
| 189 |
+
reasoning: event.reasoning,
|
| 190 |
+
action: event.action,
|
| 191 |
+
},
|
| 192 |
+
})),
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
case 'clarification':
|
| 196 |
+
return { ...base, clarification: event.question }
|
| 197 |
+
|
| 198 |
+
case 'token':
|
| 199 |
+
return { ...base, answer: base.answer + event.text }
|
| 200 |
+
|
| 201 |
+
case 'run_end':
|
| 202 |
+
return { ...base, status: 'done', summary: event }
|
| 203 |
+
|
| 204 |
+
case 'heartbeat':
|
| 205 |
+
return base
|
| 206 |
+
|
| 207 |
+
case 'error':
|
| 208 |
+
return {
|
| 209 |
+
...base,
|
| 210 |
+
status: 'error',
|
| 211 |
+
errorMessage: event.message,
|
| 212 |
+
trace: base.trace.map((entry) =>
|
| 213 |
+
entry.status === 'running' ? { ...entry, status: 'done' } : entry,
|
| 214 |
+
),
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
default:
|
| 218 |
+
return base
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
default:
|
| 223 |
+
return state
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
export function useAgentStream() {
|
| 228 |
+
const [state, dispatch] = useReducer(reducer, initialState)
|
| 229 |
+
const abortRef = useRef<AbortController | null>(null)
|
| 230 |
+
|
| 231 |
+
const cancel = useCallback(() => {
|
| 232 |
+
abortRef.current?.abort()
|
| 233 |
+
abortRef.current = null
|
| 234 |
+
}, [])
|
| 235 |
+
|
| 236 |
+
const send = useCallback(async (query: string, speed = 1) => {
|
| 237 |
+
abortRef.current?.abort()
|
| 238 |
+
const controller = new AbortController()
|
| 239 |
+
abortRef.current = controller
|
| 240 |
+
dispatch({ kind: 'reset', query })
|
| 241 |
+
|
| 242 |
+
try {
|
| 243 |
+
const response = await fetch(`/api/chat/stream?speed=${speed}`, {
|
| 244 |
+
method: 'POST',
|
| 245 |
+
headers: { 'Content-Type': 'application/json' },
|
| 246 |
+
body: JSON.stringify({ query }),
|
| 247 |
+
signal: controller.signal,
|
| 248 |
+
})
|
| 249 |
+
|
| 250 |
+
if (!response.ok) {
|
| 251 |
+
throw new Error(`Server returned ${response.status} ${response.statusText}`)
|
| 252 |
+
}
|
| 253 |
+
if (!response.body) {
|
| 254 |
+
throw new Error('Response carried no readable body')
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
const reader = response.body.getReader()
|
| 258 |
+
const decoder = new TextDecoder()
|
| 259 |
+
const parse = createSSEParser()
|
| 260 |
+
|
| 261 |
+
for (;;) {
|
| 262 |
+
const { done, value } = await reader.read()
|
| 263 |
+
if (done) break
|
| 264 |
+
for (const frame of parse(decoder.decode(value, { stream: true }))) {
|
| 265 |
+
try {
|
| 266 |
+
dispatch({ kind: 'event', event: JSON.parse(frame.data) as AgentEvent })
|
| 267 |
+
} catch {
|
| 268 |
+
// A malformed frame should not tear down a run that is otherwise fine.
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
dispatch({ kind: 'closed' })
|
| 273 |
+
} catch (error) {
|
| 274 |
+
if (error instanceof DOMException && error.name === 'AbortError') return
|
| 275 |
+
const raw = error instanceof Error ? error.message : String(error)
|
| 276 |
+
const message =
|
| 277 |
+
/connection error|failed to fetch|networkerror|load failed/i.test(raw)
|
| 278 |
+
? 'The browser dropped the stream after a long wait. Hard-refresh and send the question again — the GPU is usually warm on the second try.'
|
| 279 |
+
: raw
|
| 280 |
+
dispatch({
|
| 281 |
+
kind: 'error',
|
| 282 |
+
message,
|
| 283 |
+
})
|
| 284 |
+
} finally {
|
| 285 |
+
if (abortRef.current === controller) abortRef.current = null
|
| 286 |
+
}
|
| 287 |
+
}, [])
|
| 288 |
+
|
| 289 |
+
return { state, send, cancel }
|
| 290 |
+
}
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.tsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/tsconfig.app.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
| 4 |
+
"target": "es2023",
|
| 5 |
+
"lib": ["ES2023", "DOM"],
|
| 6 |
+
"module": "esnext",
|
| 7 |
+
"types": ["vite/client"],
|
| 8 |
+
"allowArbitraryExtensions": true,
|
| 9 |
+
"skipLibCheck": true,
|
| 10 |
+
|
| 11 |
+
/* Bundler mode */
|
| 12 |
+
"moduleResolution": "bundler",
|
| 13 |
+
"allowImportingTsExtensions": true,
|
| 14 |
+
"verbatimModuleSyntax": true,
|
| 15 |
+
"moduleDetection": "force",
|
| 16 |
+
"noEmit": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
|
| 19 |
+
/* Linting */
|
| 20 |
+
"noUnusedLocals": true,
|
| 21 |
+
"noUnusedParameters": true,
|
| 22 |
+
"erasableSyntaxOnly": true,
|
| 23 |
+
"noFallthroughCasesInSwitch": true
|
| 24 |
+
},
|
| 25 |
+
"include": ["src"]
|
| 26 |
+
}
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
frontend/tsconfig.node.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
| 4 |
+
"target": "es2023",
|
| 5 |
+
"lib": ["ES2023"],
|
| 6 |
+
"types": ["node"],
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
|
| 9 |
+
/* Bundler mode */
|
| 10 |
+
"module": "nodenext",
|
| 11 |
+
"allowImportingTsExtensions": true,
|
| 12 |
+
"verbatimModuleSyntax": true,
|
| 13 |
+
"moduleDetection": "force",
|
| 14 |
+
"noEmit": true,
|
| 15 |
+
|
| 16 |
+
/* Linting */
|
| 17 |
+
"noUnusedLocals": true,
|
| 18 |
+
"noUnusedParameters": true,
|
| 19 |
+
"erasableSyntaxOnly": true,
|
| 20 |
+
"noFallthroughCasesInSwitch": true
|
| 21 |
+
},
|
| 22 |
+
"include": ["vite.config.ts"]
|
| 23 |
+
}
|
frontend/vite.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tailwindcss from '@tailwindcss/vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
import { defineConfig } from 'vite'
|
| 4 |
+
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react(), tailwindcss()],
|
| 7 |
+
server: {
|
| 8 |
+
// Proxying rather than calling http://localhost:8080 directly keeps dev
|
| 9 |
+
// same-origin, which is how the app is served in production once FastAPI
|
| 10 |
+
// mounts the built SPA. Avoids CORS behaviour that only exists in dev.
|
| 11 |
+
proxy: {
|
| 12 |
+
'/api': {
|
| 13 |
+
target: 'http://127.0.0.1:8080',
|
| 14 |
+
changeOrigin: true,
|
| 15 |
+
},
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
})
|