fardeen16 commited on
Commit
e6c104f
·
verified ·
1 Parent(s): fd743c3

Upload FinRAG source for Render Docker build

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +16 -0
  2. Dockerfile +54 -0
  3. backend/__init__.py +1 -0
  4. backend/app/__init__.py +1 -0
  5. backend/app/agent/__init__.py +7 -0
  6. backend/app/agent/embeddings.py +94 -0
  7. backend/app/agent/graph.py +389 -0
  8. backend/app/agent/llm.py +59 -0
  9. backend/app/agent/resources.py +88 -0
  10. backend/app/agent/tools.py +197 -0
  11. backend/app/config.py +114 -0
  12. backend/app/events.py +196 -0
  13. backend/app/main.py +106 -0
  14. backend/app/mock_agent.py +384 -0
  15. backend/app/stream.py +294 -0
  16. backend/check_retrieval.py +48 -0
  17. backend/diagnose_corpus.py +51 -0
  18. backend/diagnose_retrieval.py +68 -0
  19. backend/ingest/__init__.py +1 -0
  20. backend/ingest/enrich.py +190 -0
  21. backend/ingest/extract.py +159 -0
  22. backend/ingest/pipeline.py +217 -0
  23. backend/inspect_submission.py +60 -0
  24. backend/measure_corpus.py +121 -0
  25. backend/probe_denominator.py +69 -0
  26. backend/probe_partition.py +73 -0
  27. backend/probe_tables.py +70 -0
  28. backend/reindex.py +198 -0
  29. backend/requirements.txt +18 -0
  30. backend/run_agent.py +155 -0
  31. backend/smoke_live.py +48 -0
  32. data/alphabet_financials_structured.csv +9 -0
  33. frontend/index.html +13 -0
  34. frontend/package-lock.json +2363 -0
  35. frontend/package.json +26 -0
  36. frontend/src/App.tsx +226 -0
  37. frontend/src/assets/hero.png +0 -0
  38. frontend/src/assets/react.svg +1 -0
  39. frontend/src/assets/vite.svg +1 -0
  40. frontend/src/components/AgentTrace.tsx +177 -0
  41. frontend/src/components/LatencyBreakdown.tsx +78 -0
  42. frontend/src/index.css +49 -0
  43. frontend/src/lib/events.ts +140 -0
  44. frontend/src/lib/sse.ts +55 -0
  45. frontend/src/lib/useAgentStream.ts +290 -0
  46. frontend/src/main.tsx +10 -0
  47. frontend/tsconfig.app.json +26 -0
  48. frontend/tsconfig.json +7 -0
  49. frontend/tsconfig.node.json +23 -0
  50. 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
+ })