From fee0db4cb6889bc223208a61ecfbd8d77e4fe661 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sun, 11 Oct 2026 11:47:45 -0400 Subject: [PATCH 01/16] Fix analysis-quality bugs in dedup, embeddings, RAG and outliers - Dedup chunks across the whole corpus instead of per article, so syndicated copies of a story on different sites are caught - Add nomic-embed-text task prefixes (search_document / search_query) - Restore vector-search rank order before picking one chunk per article in RAG (SQLite IN returned rows in arbitrary order) - Outliers: one row per article, cluster filter applied server-side - Sanitize LLM markdown with DOMPurify before {@html} Co-Authored-By: Claude Opus 5.5 --- backend/api/main.py | 89 ++++++++--------- backend/embedding/embed.py | 19 +++- backend/processing/dedup.py | 15 +++ backend/synthesis/rag.py | 27 +++-- frontend/package-lock.json | 12 ++- frontend/package.json | 1 + frontend/src/components/ChatPanel.svelte | 4 +- frontend/src/routes/+page.svelte | 29 +++--- tests/test_quality_fixes.py | 121 +++++++++++++++++++++++ 9 files changed, 248 insertions(+), 69 deletions(-) create mode 100644 tests/test_quality_fixes.py diff --git a/backend/api/main.py b/backend/api/main.py index 250eec6..e362698 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -156,42 +156,44 @@ def get_clusters(): return [dict(r) for r in rows] +def fetch_outliers( + conn: sqlite3.Connection, cluster_id: int | None = None, limit: int = 50 +) -> list[dict]: + """One row per article — its chunk furthest from its cluster centroid — + ordered by that distance, optionally restricted to one cluster.""" + rows = conn.execute( + """ + SELECT chunk_id, cluster_id, cluster_label, distance_from_centroid, + article_id, title, domain, publish_date + FROM ( + SELECT p.chunk_id, p.cluster_id, p.cluster_label, + p.distance_from_centroid, + c.article_id, a.title, a.domain, a.publish_date, + ROW_NUMBER() OVER ( + PARTITION BY c.article_id + ORDER BY p.distance_from_centroid DESC + ) AS rn + FROM projections p + JOIN chunks c ON c.id = p.chunk_id + JOIN articles a ON a.id = c.article_id + WHERE a.status = 'ok' AND (:cid IS NULL OR p.cluster_id = :cid) + ) + WHERE rn = 1 + ORDER BY distance_from_centroid DESC + LIMIT :limit + """, + {"cid": cluster_id, "limit": limit}, + ).fetchall() + return [dict(r) for r in rows] + + @app.get("/api/outliers") def get_outliers(cluster_id: int | None = None): conn = get_db() try: - if cluster_id is not None: - rows = conn.execute( - """ - SELECT p.chunk_id, p.cluster_id, p.cluster_label, - p.distance_from_centroid, - c.article_id, a.title, a.domain, a.publish_date - FROM projections p - JOIN chunks c ON c.id = p.chunk_id - JOIN articles a ON a.id = c.article_id - WHERE a.status = 'ok' AND p.cluster_id = ? - ORDER BY p.distance_from_centroid DESC - LIMIT 50 - """, - (cluster_id,), - ).fetchall() - else: - rows = conn.execute( - """ - SELECT p.chunk_id, p.cluster_id, p.cluster_label, - p.distance_from_centroid, - c.article_id, a.title, a.domain, a.publish_date - FROM projections p - JOIN chunks c ON c.id = p.chunk_id - JOIN articles a ON a.id = c.article_id - WHERE a.status = 'ok' - ORDER BY p.distance_from_centroid DESC - LIMIT 50 - """ - ).fetchall() + return fetch_outliers(conn, cluster_id) finally: conn.close() - return [dict(r) for r in rows] @app.get("/api/articles/{article_id}") @@ -344,8 +346,7 @@ async def run_pipeline(job_id: str, topic: str) -> None: from backend.ingestion.crawler import crawl_urls from backend.ingestion.extractor import extract_article from backend.processing.metadata import extract_publish_date - from backend.processing.chunker import chunk_article - from backend.processing.dedup import mark_duplicates + from backend.processing.dedup import chunk_and_dedup try: # Clear previous corpus before starting fresh @@ -416,21 +417,17 @@ def _extract_and_store() -> tuple[int, list[dict]]: update_job(job_id, progress=75) def _chunk_and_dedup(articles: list[dict]) -> None: + chunks = chunk_and_dedup(articles) conn = sqlite3.connect(DB_PATH) try: - for art in articles: - if not art.get("raw_text"): - continue - chunks = chunk_article(art["id"], art["raw_text"]) - chunks = mark_duplicates(chunks) - conn.executemany( - "INSERT OR IGNORE INTO chunks " - "(id, article_id, parent_id, parent_text, child_text, " - " chunk_index, token_count, is_duplicate) " - "VALUES (:id, :article_id, :parent_id, :parent_text, :child_text, " - " :chunk_index, :token_count, :is_duplicate)", - chunks, - ) + conn.executemany( + "INSERT OR IGNORE INTO chunks " + "(id, article_id, parent_id, parent_text, child_text, " + " chunk_index, token_count, is_duplicate) " + "VALUES (:id, :article_id, :parent_id, :parent_text, :child_text, " + " :chunk_index, :token_count, :is_duplicate)", + chunks, + ) conn.commit() finally: conn.close() @@ -471,7 +468,7 @@ def _embed_and_store() -> None: for r in rows ] - embeddings = embed_chunks(chunks_for_embed) + embeddings = embed_chunks(chunks_for_embed, kind="document") # Build full rows for LanceDB (merge embed result with metadata) meta_by_id = {c["id"]: c for c in chunks_for_embed} diff --git a/backend/embedding/embed.py b/backend/embedding/embed.py index 8a579fa..8f454e1 100644 --- a/backend/embedding/embed.py +++ b/backend/embedding/embed.py @@ -19,6 +19,10 @@ OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") OLLAMA_MODEL = "nomic-embed-text" +# nomic-embed-text is trained with task prefixes; without them retrieval and +# clustering quality drop. OpenAI models take raw text. +_NOMIC_PREFIXES = {"document": "search_document: ", "query": "search_query: "} + def _batched(items: list, size: int) -> Generator[list, None, None]: for i in range(0, len(items), size): @@ -67,9 +71,20 @@ def _embed_openai(texts: list[str]) -> list[list[float]]: return [item.embedding for item in resp.data] -def embed_chunks(chunks: list[dict]) -> list[dict]: +def prepare_texts(texts: list[str], kind: str) -> list[str]: + """Apply the model's task prefix. kind is 'document' or 'query'.""" + if kind not in _NOMIC_PREFIXES: + raise ValueError(f"kind must be 'document' or 'query', got {kind!r}") + if EMBEDDING_MODE != "local": + return texts + prefix = _NOMIC_PREFIXES[kind] + return [prefix + t for t in texts] + + +def embed_chunks(chunks: list[dict], kind: str = "document") -> list[dict]: """ Takes a list of chunk dicts (must have 'id' and 'child_text'). + kind: 'document' for corpus chunks, 'query' for search queries. Returns list of dicts: {chunk_id, vector}. Processes in batches of BATCH_SIZE. """ @@ -78,7 +93,7 @@ def embed_chunks(chunks: list[dict]) -> list[dict]: embed_fn = _embed_local if EMBEDDING_MODE == "local" else _embed_openai for batch in _batched(chunks, BATCH_SIZE): - texts = [c["child_text"] for c in batch] + texts = prepare_texts([c["child_text"] for c in batch], kind) vectors = embed_fn(texts) for chunk, vector in zip(batch, vectors): results.append({"chunk_id": chunk["id"], "vector": vector}) diff --git a/backend/processing/dedup.py b/backend/processing/dedup.py index bd2829c..a6075f5 100644 --- a/backend/processing/dedup.py +++ b/backend/processing/dedup.py @@ -57,3 +57,18 @@ def mark_duplicates(chunks: list[dict]) -> list[dict]: lsh.insert(cid, m) return chunks + + +def chunk_and_dedup(articles: list[dict]) -> list[dict]: + """ + Chunk every article (dicts with 'id' and 'raw_text'), then dedup across the + whole corpus in one pass so syndicated copies of the same story on different + sites are caught. Earlier articles keep their chunks; later copies are marked. + """ + from backend.processing.chunker import chunk_article + + chunks: list[dict] = [] + for art in articles: + if art.get("raw_text"): + chunks.extend(chunk_article(art["id"], art["raw_text"])) + return mark_duplicates(chunks) diff --git a/backend/synthesis/rag.py b/backend/synthesis/rag.py index 022c3b1..f3dbadc 100644 --- a/backend/synthesis/rag.py +++ b/backend/synthesis/rag.py @@ -20,6 +20,22 @@ TABLE_NAME = "embeddings" +def order_and_dedupe_by_article(rows: list[dict], ranked_chunk_ids: list[str]) -> list[dict]: + """ + Put SQLite rows (which come back in arbitrary order) into vector-search rank + order, then keep only the highest-ranked chunk per article. + """ + rank = {cid: i for i, cid in enumerate(ranked_chunk_ids)} + ordered = sorted(rows, key=lambda r: rank.get(r["chunk_id"], len(rank))) + seen_articles: set[str] = set() + deduped = [] + for row in ordered: + if row["article_id"] not in seen_articles: + seen_articles.add(row["article_id"]) + deduped.append(row) + return deduped + + def query_corpus(query: str, db_path: str | None = None, top_k: int | None = None) -> dict: _db_path = db_path or DB_PATH _top_k = top_k or int(os.getenv("RAG_TOP_K", "15")) @@ -27,7 +43,7 @@ def query_corpus(query: str, db_path: str | None = None, top_k: int | None = Non # --- 1. Embed the query --- try: from backend.embedding.embed import embed_chunks - embedded = embed_chunks([{"id": "query", "child_text": query}]) + embedded = embed_chunks([{"id": "query", "child_text": query}], kind="query") query_vector = embedded[0]["vector"] except Exception as e: return {"answer": f"Embedding error: {e}", "citations": []} @@ -66,13 +82,8 @@ def query_corpus(query: str, db_path: str | None = None, top_k: int | None = Non finally: conn.close() - # --- 4. Deduplicate by article (keep first occurrence per article_id) --- - seen_articles: set[str] = set() - deduped = [] - for row in rows: - if row["article_id"] not in seen_articles: - seen_articles.add(row["article_id"]) - deduped.append(dict(row)) + # --- 4. Deduplicate by article, keeping each article's best-ranked chunk --- + deduped = order_and_dedupe_by_article([dict(r) for r in rows], chunk_ids) if not deduped: return {"answer": "Could not retrieve context from the corpus.", "citations": []} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0618da7..cf0f33a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "d3": "^7.9.0", + "dompurify": "^3.4.16", "marked": "^18.0.2" }, "devDependencies": { @@ -1203,7 +1204,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@typescript-eslint/types": { @@ -1884,6 +1885,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index ee65354..9696c51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "d3": "^7.9.0", + "dompurify": "^3.4.16", "marked": "^18.0.2" } } diff --git a/frontend/src/components/ChatPanel.svelte b/frontend/src/components/ChatPanel.svelte index 0383f4b..42274c0 100644 --- a/frontend/src/components/ChatPanel.svelte +++ b/frontend/src/components/ChatPanel.svelte @@ -2,6 +2,7 @@ import { api } from '../lib/api.js'; import { PRESET_QUERIES } from '../lib/presets.js'; import { marked } from 'marked'; + import DOMPurify from 'dompurify'; import { afterUpdate } from 'svelte'; marked.setOptions({ breaks: true, gfm: true }); @@ -46,7 +47,8 @@ } function renderMd(text) { - return marked.parse(text || ''); + // LLM output can echo text from scraped pages — never trust it as HTML. + return DOMPurify.sanitize(marked.parse(text || '')); } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 79e61b2..2d5e3b2 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -54,9 +54,21 @@ let exportError = null; // --- Computed --- - $: filteredOutliers = selectedCluster == null - ? outliers - : outliers.filter(o => o.cluster_id === selectedCluster); + // Outliers are fetched per cluster from the server; filtering the global + // top-N on the client left small clusters with an empty list. + let outlierRequest = 0; + $: if (phase === 'results') refreshOutliers(selectedCluster); + + async function refreshOutliers(clusterId) { + const request = ++outlierRequest; + try { + const data = await api.getOutliers(clusterId); + if (request === outlierRequest) outliers = data; + } catch (e) { + console.error('Failed to load outliers', e); + if (request === outlierRequest) outliers = []; + } + } $: clusterCount = [...new Set(points.map(p => p.cluster_id))].length; @@ -109,12 +121,7 @@ async function loadResults() { try { - const [corpusData, outliersData] = await Promise.all([ - api.getCorpus(), - api.getOutliers(), - ]); - points = corpusData; - outliers = outliersData; + points = await api.getCorpus(); phase = 'results'; } catch (e) { jobError = e.message; @@ -309,7 +316,7 @@
@@ -338,7 +345,7 @@ diff --git a/tests/test_quality_fixes.py b/tests/test_quality_fixes.py new file mode 100644 index 0000000..e6d66a6 --- /dev/null +++ b/tests/test_quality_fixes.py @@ -0,0 +1,121 @@ +import sqlite3 +from pathlib import Path + +import pytest + +from backend.processing.dedup import chunk_and_dedup +from backend.embedding import embed +from backend.synthesis.rag import order_and_dedupe_by_article +from backend.api.main import fetch_outliers + + +# ── cross-article dedup ────────────────────────────────────────────────────── + +SYNDICATED = ( + "The central bank raised interest rates by a quarter point on Wednesday, " + "citing persistent inflation in services and a resilient labour market. " +) * 20 + + +def test_dedup_catches_duplicates_across_articles(): + articles = [ + {"id": "a1", "raw_text": SYNDICATED}, + {"id": "a2", "raw_text": SYNDICATED}, + ] + chunks = chunk_and_dedup(articles) + a1 = [c for c in chunks if c["article_id"] == "a1"] + a2 = [c for c in chunks if c["article_id"] == "a2"] + assert a1 and a2 + assert all(c["is_duplicate"] == 1 for c in a2) + # the first copy keeps at least one chunk + assert any(c["is_duplicate"] == 0 for c in a1) + + +def test_dedup_keeps_distinct_articles(): + articles = [ + {"id": "a1", "raw_text": SYNDICATED}, + {"id": "a2", "raw_text": "Coral reefs bleach when ocean temperatures stay high. " * 40}, + ] + chunks = chunk_and_dedup(articles) + assert any(c["is_duplicate"] == 0 for c in chunks if c["article_id"] == "a2") + + +def test_dedup_skips_empty_text(): + assert chunk_and_dedup([{"id": "a1", "raw_text": ""}]) == [] + + +# ── nomic task prefixes ────────────────────────────────────────────────────── + +def test_nomic_document_prefix(monkeypatch): + monkeypatch.setattr(embed, "EMBEDDING_MODE", "local") + assert embed.prepare_texts(["hi"], "document") == ["search_document: hi"] + + +def test_nomic_query_prefix(monkeypatch): + monkeypatch.setattr(embed, "EMBEDDING_MODE", "local") + assert embed.prepare_texts(["hi"], "query") == ["search_query: hi"] + + +def test_openai_has_no_prefix(monkeypatch): + monkeypatch.setattr(embed, "EMBEDDING_MODE", "openai") + assert embed.prepare_texts(["hi"], "query") == ["hi"] + + +def test_unknown_kind_rejected(): + with pytest.raises(ValueError): + embed.prepare_texts(["hi"], "passage") + + +# ── RAG ranking order ──────────────────────────────────────────────────────── + +def test_rag_keeps_best_ranked_chunk_per_article(): + ranked_ids = ["a2#p0c1", "a1#p0c0", "a2#p0c0"] + # SQLite returns rows in arbitrary order + rows = [ + {"chunk_id": "a2#p0c0", "article_id": "a2", "parent_text": "worse"}, + {"chunk_id": "a1#p0c0", "article_id": "a1", "parent_text": "second"}, + {"chunk_id": "a2#p0c1", "article_id": "a2", "parent_text": "best"}, + ] + out = order_and_dedupe_by_article(rows, ranked_ids) + assert [r["chunk_id"] for r in out] == ["a2#p0c1", "a1#p0c0"] + + +# ── outliers ───────────────────────────────────────────────────────────────── + +@pytest.fixture +def db(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + schema = Path(__file__).parent.parent / "backend" / "db" / "schema.sql" + conn.executescript(schema.read_text()) + conn.executemany( + "INSERT INTO articles (id, url, title, status) VALUES (?,?,?,?)", + [("a1", "u1", "A1", "ok"), ("a2", "u2", "A2", "ok"), ("a3", "u3", "A3", "ok")], + ) + chunks = [("a1#0", "a1"), ("a1#1", "a1"), ("a1#2", "a1"), ("a2#0", "a2"), ("a3#0", "a3")] + conn.executemany("INSERT INTO chunks (id, article_id) VALUES (?,?)", chunks) + conn.executemany( + "INSERT INTO projections (chunk_id, cluster_id, cluster_label, distance_from_centroid) " + "VALUES (?,?,?,?)", + [ + ("a1#0", 0, "zero", 9.0), + ("a1#1", 0, "zero", 8.0), + ("a1#2", 0, "zero", 7.0), + ("a2#0", 0, "zero", 1.0), + ("a3#0", 1, "one", 5.0), + ], + ) + yield conn + conn.close() + + +def test_outliers_one_row_per_article(db): + rows = fetch_outliers(db) + assert [r["article_id"] for r in rows] == ["a1", "a3", "a2"] + assert rows[0]["chunk_id"] == "a1#0" + assert rows[0]["distance_from_centroid"] == 9.0 + + +def test_outliers_filtered_by_cluster(db): + rows = fetch_outliers(db, cluster_id=1) + assert [r["article_id"] for r in rows] == ["a3"] From b2b4b06c564b989adcca61435b21dbc1ed687447 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sun, 11 Oct 2026 11:58:53 -0400 Subject: [PATCH 02/16] Move to uv, pydantic-settings and current dependencies - pyproject.toml + uv.lock replace requirements.txt; Python pinned to 3.12 - backend/config.py: one pydantic-settings Settings object replaces os.getenv calls scattered across modules - FastAPI lifespan replaces deprecated on_event("startup") - anthropic 0.34 -> 1.x; default model claude-sonnet-5-5. New claude_client.complete() collects text blocks (current models think by default, so content[0] may not be text), raises on refusal, and uses server-side fallback on the Anthropic API. Label/query calls run at low effort with room for thinking (max_tokens 20 would be consumed by it) - lancedb 0.9 -> 0.40 (paginated list_tables), trafilatura 2.x, openai, numpy 2, pandas, scikit-learn upgrades - ruff config; zip(strict=True) so a short embedding batch fails loudly Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 +- .gitignore | 1 + .python-version | 1 + README.md | 13 +- backend/api/main.py | 46 +- backend/claude_client.py | 76 +- backend/config.py | 48 + backend/embedding/embed.py | 19 +- backend/embedding/project.py | 27 +- backend/embedding/store.py | 34 +- backend/export.py | 1 - backend/ingestion/extractor.py | 3 +- backend/ingestion/search.py | 34 +- backend/processing/chunker.py | 2 +- backend/processing/dedup.py | 1 - backend/processing/metadata.py | 1 - backend/synthesis/rag.py | 28 +- pyproject.toml | 50 + requirements.txt | 16 - tests/test_export.py | 2 +- tests/test_quality_fixes.py | 12 +- uv.lock | 2451 ++++++++++++++++++++++++++++++++ 22 files changed, 2712 insertions(+), 162 deletions(-) create mode 100644 .python-version create mode 100644 backend/config.py create mode 100644 pyproject.toml delete mode 100644 requirements.txt create mode 100644 uv.lock diff --git a/.env.example b/.env.example index f17b822..f101a66 100644 --- a/.env.example +++ b/.env.example @@ -6,13 +6,13 @@ CLAUDE_BACKEND=anthropic # Anthropic API (used when CLAUDE_BACKEND=anthropic) ANTHROPIC_API_KEY=your_key_here -CLAUDE_MODEL=claude-sonnet-4-6 # override default model +CLAUDE_MODEL=claude-sonnet-5-5 # override default model # Vertex AI (used when CLAUDE_BACKEND=vertex) -# Prerequisites: pip install anthropic[vertex] && gcloud auth application-default login +# Prerequisites: uv sync --extra vertex && gcloud auth application-default login GOOGLE_CLOUD_PROJECT=your_gcp_project_id -VERTEX_REGION=us-east5 # region where Claude is deployed in your project -VERTEX_CLAUDE_MODEL=claude-sonnet-4-0@20250514 # override Vertex model +VERTEX_REGION=global # global, a multi-region (us / eu), or a specific region +VERTEX_CLAUDE_MODEL=claude-sonnet-5-5 # override Vertex model # Embeddings — choose one mode EMBEDDING_MODE=local # local | openai diff --git a/.gitignore b/.gitignore index a3d14a4..19ec5f7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ frontend/build/ # Claude .claude/ +.venv/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md index 63e2e30..dc32675 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Discourse Lens takes a topic string as input, crawls ~100 relevant articles from ## Requirements -- Python 3.10+ +- Python 3.12 and [uv](https://docs.astral.sh/uv/) - Node.js 18+ - [Ollama](https://ollama.com) (for local embeddings) — or an OpenAI API key - A [Brave Search API key](https://brave.com/search/api) (free tier: 2000 queries/month) @@ -21,12 +21,12 @@ Discourse Lens takes a topic string as input, crawls ~100 relevant articles from ```bash git clone https://github.com/Meerav29/discourse-lens.git cd discourse-lens -python -m venv venv -source venv/Scripts/activate # Windows Git Bash -# source venv/bin/activate # Mac/Linux -pip install -r requirements.txt +uv sync # creates .venv from uv.lock +uv run playwright install chromium ``` +Using Vertex AI for Claude? `uv sync --extra vertex` instead. + **2. Install frontend dependencies** ```bash @@ -65,8 +65,7 @@ Open two terminals. **Terminal 1 — Backend:** ```bash -source venv/Scripts/activate # Windows Git Bash / source venv/bin/activate on Mac/Linux -python -m uvicorn backend.api.main:app --reload --port 8001 +uv run uvicorn backend.api.main:app --reload --port 8001 ``` **Terminal 2 — Frontend:** diff --git a/backend/api/main.py b/backend/api/main.py index e362698..8c2ce31 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -2,32 +2,37 @@ import hashlib import io import json as json_lib -import os import re import sqlite3 +import sys import time import uuid import zipfile +from contextlib import asynccontextmanager from pathlib import Path -from dotenv import load_dotenv + from fastapi import BackgroundTasks, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -import asyncio -import sys + +from backend.config import settings if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) -load_dotenv() -DB_PATH = os.getenv("DB_PATH", "./data/discourse.db") +@asynccontextmanager +async def lifespan(_app: FastAPI): + Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True) + init_db() + yield + -app = FastAPI(title="Discourse Lens API") +app = FastAPI(title="Discourse Lens API", lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173"], + allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -39,7 +44,7 @@ # --------------------------------------------------------------------------- def get_db() -> sqlite3.Connection: - conn = sqlite3.connect(DB_PATH) + conn = sqlite3.connect(settings.db_path) conn.row_factory = sqlite3.Row return conn @@ -67,16 +72,6 @@ def update_job(job_id: str, **kwargs) -> None: conn.close() -# --------------------------------------------------------------------------- -# Startup -# --------------------------------------------------------------------------- - -@app.on_event("startup") -async def startup() -> None: - Path("data").mkdir(exist_ok=True) - init_db() - - # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -223,6 +218,7 @@ def chat(req: ChatRequest): @app.get("/api/export") def export_obsidian(): from fastapi.responses import StreamingResponse + from backend.export import build_canvas, build_vault_files conn = get_db() @@ -342,11 +338,11 @@ async def event_generator(): async def run_pipeline(job_id: str, topic: str) -> None: """Full ingestion + processing pipeline: search → crawl → extract → chunk → dedup.""" - from backend.ingestion.search import collect_urls from backend.ingestion.crawler import crawl_urls from backend.ingestion.extractor import extract_article - from backend.processing.metadata import extract_publish_date + from backend.ingestion.search import collect_urls from backend.processing.dedup import chunk_and_dedup + from backend.processing.metadata import extract_publish_date try: # Clear previous corpus before starting fresh @@ -373,7 +369,7 @@ async def run_pipeline(job_id: str, topic: str) -> None: def _extract_and_store() -> tuple[int, list[dict]]: ok_count = 0 ok_articles: list[dict] = [] - conn = sqlite3.connect(DB_PATH) + conn = sqlite3.connect(settings.db_path) try: for item in crawled: url = item["url"] @@ -418,7 +414,7 @@ def _extract_and_store() -> tuple[int, list[dict]]: def _chunk_and_dedup(articles: list[dict]) -> None: chunks = chunk_and_dedup(articles) - conn = sqlite3.connect(DB_PATH) + conn = sqlite3.connect(settings.db_path) try: conn.executemany( "INSERT OR IGNORE INTO chunks " @@ -441,7 +437,7 @@ def _embed_and_store() -> None: from backend.embedding.embed import embed_chunks from backend.embedding.store import write_embeddings - conn = sqlite3.connect(DB_PATH) + conn = sqlite3.connect(settings.db_path) conn.row_factory = sqlite3.Row try: rows = conn.execute( @@ -495,7 +491,7 @@ def _embed_and_store() -> None: def _project() -> None: from backend.embedding.project import run_projection - run_projection(db_path=DB_PATH) + run_projection(db_path=settings.db_path) await asyncio.to_thread(_project) diff --git a/backend/claude_client.py b/backend/claude_client.py index deac72d..5a76b36 100644 --- a/backend/claude_client.py +++ b/backend/claude_client.py @@ -1,39 +1,87 @@ """ -Shared Claude client factory. +Shared Claude client. -Supports two backends, selected by the CLAUDE_BACKEND env var: +Supports two backends, selected by CLAUDE_BACKEND: anthropic (default) — Direct Anthropic API, authenticated via ANTHROPIC_API_KEY vertex — Google Cloud Vertex AI, authenticated via Application Default Credentials Vertex AI setup: - 1. pip install anthropic[vertex] (adds google-auth) + 1. uv sync --extra vertex (adds google-auth) 2. gcloud auth application-default login 3. Set GOOGLE_CLOUD_PROJECT and VERTEX_REGION in .env """ from __future__ import annotations -import os +from functools import lru_cache +from typing import Literal -CLAUDE_BACKEND = os.getenv("CLAUDE_BACKEND", "anthropic") +from backend.config import settings -# Model IDs differ between backends — Vertex uses @ format. -_ANTHROPIC_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6") -_VERTEX_MODEL = os.getenv("VERTEX_CLAUDE_MODEL", "claude-sonnet-4-0@20250514") +Effort = Literal["low", "medium", "high"] +class ClaudeRefusal(RuntimeError): + """Claude declined the request (stop_reason == 'refusal').""" + + +@lru_cache(maxsize=1) def get_client(): """Return an Anthropic-compatible messages client for the configured backend.""" - if CLAUDE_BACKEND == "vertex": + if settings.claude_backend == "vertex": from anthropic import AnthropicVertex # requires anthropic[vertex] + if not settings.google_cloud_project: + raise RuntimeError("GOOGLE_CLOUD_PROJECT must be set when CLAUDE_BACKEND=vertex") return AnthropicVertex( - project_id=os.environ["GOOGLE_CLOUD_PROJECT"], - region=os.getenv("VERTEX_REGION", "us-east5"), + project_id=settings.google_cloud_project, + region=settings.vertex_region, ) from anthropic import Anthropic - return Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) + return Anthropic(api_key=settings.anthropic_api_key) def get_model() -> str: - """Return the correct model ID string for the configured backend.""" - return _VERTEX_MODEL if CLAUDE_BACKEND == "vertex" else _ANTHROPIC_MODEL + """Return the model ID for the configured backend.""" + if settings.claude_backend == "vertex": + return settings.vertex_claude_model + return settings.claude_model + + +def complete( + prompt: str, + *, + system: str | None = None, + max_tokens: int = 16000, + effort: Effort = "medium", +) -> str: + """ + Single-turn request; returns the concatenated text blocks. + + Current models think by default, so content[0] may be a thinking block — + always collect the text blocks. On the Anthropic API, server-side fallback + re-runs a refused request on a suitable model within the same call. + """ + kwargs = dict( + model=get_model(), + max_tokens=max_tokens, + messages=[{"role": "user", "content": prompt}], + output_config={"effort": effort}, + ) + if system: + kwargs["system"] = system + + client = get_client() + if settings.claude_backend == "anthropic": + response = client.beta.messages.create( + betas=["server-side-fallback-2026-07-01"], + fallbacks="default", + **kwargs, + ) + else: + response = client.messages.create(**kwargs) + + if response.stop_reason == "refusal": + category = response.stop_details.category if response.stop_details else None + raise ClaudeRefusal(f"Claude declined the request (category: {category})") + + return "".join(b.text for b in response.content if b.type == "text").strip() diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..a90286e --- /dev/null +++ b/backend/config.py @@ -0,0 +1,48 @@ +""" +All configuration, read once from the environment / .env file. + +Import `settings` and read attributes at call time (not at import time) so +tests can monkeypatch them. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + # Search + brave_api_key: str = "" + max_articles: int = 120 + + # Claude + claude_backend: Literal["anthropic", "vertex"] = "anthropic" + claude_model: str = "claude-sonnet-5-5" + anthropic_api_key: str | None = None # SDK also resolves ANTHROPIC_API_KEY itself + google_cloud_project: str | None = None + vertex_region: str = "global" + vertex_claude_model: str = "claude-sonnet-5-5" + + # Embeddings + embedding_mode: Literal["local", "openai"] = "local" + ollama_base_url: str = "http://localhost:11434" + openai_api_key: str | None = None + + # Storage + db_path: str = "./data/discourse.db" + lance_path: str = "./data/vectors" + + # Tuning + umap_n_neighbors: int = 15 + kmeans_k: str = "auto" # "auto" | integer string + rag_top_k: int = 15 + + # Frontend dev server origin allowed by CORS + cors_origins: list[str] = ["http://localhost:5173"] + + +settings = Settings() diff --git a/backend/embedding/embed.py b/backend/embedding/embed.py index 8f454e1..5b32196 100644 --- a/backend/embedding/embed.py +++ b/backend/embedding/embed.py @@ -9,14 +9,13 @@ from __future__ import annotations -import os -from typing import Generator +from collections.abc import Generator import httpx +from backend.config import settings + BATCH_SIZE = 32 -EMBEDDING_MODE = os.getenv("EMBEDDING_MODE", "local") -OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") OLLAMA_MODEL = "nomic-embed-text" # nomic-embed-text is trained with task prefixes; without them retrieval and @@ -31,7 +30,7 @@ def _batched(items: list, size: int) -> Generator[list, None, None]: def _embed_local(texts: list[str]) -> list[list[float]]: """Call Ollama embed endpoint. Tries /api/embed (>=0.1.33) then falls back to /api/embeddings.""" - url = f"{OLLAMA_BASE_URL}/api/embed" + url = f"{settings.ollama_base_url}/api/embed" resp = httpx.post( url, json={"model": OLLAMA_MODEL, "input": texts}, @@ -46,7 +45,7 @@ def _embed_local(texts: list[str]) -> list[list[float]]: def _embed_local_legacy(texts: list[str]) -> list[list[float]]: """Ollama <0.1.33 fallback: /api/embeddings, one text at a time.""" - url = f"{OLLAMA_BASE_URL}/api/embeddings" + url = f"{settings.ollama_base_url}/api/embeddings" results = [] for text in texts: resp = httpx.post( @@ -63,7 +62,7 @@ def _embed_openai(texts: list[str]) -> list[list[float]]: """Call OpenAI embeddings API for a batch of texts.""" from openai import OpenAI - client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + client = OpenAI(api_key=settings.openai_api_key) resp = client.embeddings.create( model="text-embedding-3-small", input=texts, @@ -75,7 +74,7 @@ def prepare_texts(texts: list[str], kind: str) -> list[str]: """Apply the model's task prefix. kind is 'document' or 'query'.""" if kind not in _NOMIC_PREFIXES: raise ValueError(f"kind must be 'document' or 'query', got {kind!r}") - if EMBEDDING_MODE != "local": + if settings.embedding_mode != "local": return texts prefix = _NOMIC_PREFIXES[kind] return [prefix + t for t in texts] @@ -90,12 +89,12 @@ def embed_chunks(chunks: list[dict], kind: str = "document") -> list[dict]: """ results: list[dict] = [] - embed_fn = _embed_local if EMBEDDING_MODE == "local" else _embed_openai + embed_fn = _embed_local if settings.embedding_mode == "local" else _embed_openai for batch in _batched(chunks, BATCH_SIZE): texts = prepare_texts([c["child_text"] for c in batch], kind) vectors = embed_fn(texts) - for chunk, vector in zip(batch, vectors): + for chunk, vector in zip(batch, vectors, strict=True): results.append({"chunk_id": chunk["id"], "vector": vector}) return results diff --git a/backend/embedding/project.py b/backend/embedding/project.py index c765ae9..3dae629 100644 --- a/backend/embedding/project.py +++ b/backend/embedding/project.py @@ -13,29 +13,23 @@ from __future__ import annotations -import math -import os import sqlite3 import numpy as np -from umap import UMAP from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score +from umap import UMAP +from backend.config import settings from backend.embedding.store import read_all_embeddings, update_cluster_ids -DB_PATH = os.getenv("DB_PATH", "./data/discourse.db") -UMAP_N_NEIGHBORS = int(os.getenv("UMAP_N_NEIGHBORS", "15")) -KMEANS_K = os.getenv("KMEANS_K", "auto") # "auto" | integer string - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _best_k(vectors: np.ndarray) -> int: - if KMEANS_K != "auto": - return int(KMEANS_K) + if settings.kmeans_k != "auto": + return int(settings.kmeans_k) candidates = [k for k in (5, 7, 10) if k < len(vectors)] if not candidates: @@ -57,7 +51,7 @@ def _euclidean(a: np.ndarray, b: np.ndarray) -> float: def _label_cluster(cluster_id: int, top_chunks: list[str]) -> str: """Ask Claude for a 2-3 word label for this cluster.""" - from backend.claude_client import get_client, get_model + from backend.claude_client import complete excerpts = "\n\n".join(f"- {t[:300]}" for t in top_chunks) prompt = ( @@ -66,12 +60,7 @@ def _label_cluster(cluster_id: int, top_chunks: list[str]) -> str: "Give a 2-3 word descriptive label that captures the main theme of this cluster. " "Respond with ONLY the label, nothing else." ) - msg = get_client().messages.create( - model=get_model(), - max_tokens=20, - messages=[{"role": "user", "content": prompt}], - ) - return msg.content[0].text.strip() + return complete(prompt, max_tokens=2048, effort="low") # --------------------------------------------------------------------------- @@ -82,7 +71,7 @@ def run_projection(db_path: str | None = None) -> None: """ Full projection pipeline. Writes rows to the SQLite projections table. """ - _db_path = db_path or DB_PATH + _db_path = db_path or settings.db_path # 1. Load embeddings rows = read_all_embeddings() @@ -94,7 +83,7 @@ def run_projection(db_path: str | None = None) -> None: child_texts = {r["chunk_id"]: r["child_text"] for r in rows} # 2. UMAP - n_neighbors = min(UMAP_N_NEIGHBORS, len(vectors) - 1) + n_neighbors = min(settings.umap_n_neighbors, len(vectors) - 1) reducer = UMAP(n_components=2, n_neighbors=n_neighbors, min_dist=0.1, random_state=42) coords_2d = reducer.fit_transform(vectors) # shape (N, 2) diff --git a/backend/embedding/store.py b/backend/embedding/store.py index 4ac14ae..c019bb4 100644 --- a/backend/embedding/store.py +++ b/backend/embedding/store.py @@ -7,19 +7,28 @@ from __future__ import annotations -import os - import lancedb import pyarrow as pa -LANCE_PATH = os.getenv("LANCE_PATH", "./data/vectors") +from backend.config import settings + TABLE_NAME = "embeddings" -MAX_ARTICLES = int(os.getenv("MAX_ARTICLES", "120")) -def _get_table(db: lancedb.DBConnection) -> lancedb.table.Table: - names = db.table_names() - if TABLE_NAME in names: +def table_exists(db: lancedb.DBConnection, name: str) -> bool: + """list_tables() is paginated — walk every page.""" + page_token = None + while True: + resp = db.list_tables(page_token=page_token) + if name in resp.tables: + return True + page_token = resp.page_token + if not page_token: + return False + + +def _get_table(db: lancedb.DBConnection) -> lancedb.table.Table | None: + if table_exists(db, TABLE_NAME): return db.open_table(TABLE_NAME) return None @@ -33,7 +42,7 @@ def write_embeddings(rows: list[dict]) -> None: if not rows: return - db = lancedb.connect(LANCE_PATH) + db = lancedb.connect(settings.lance_path) table = _get_table(db) if table is None: @@ -60,7 +69,7 @@ def write_embeddings(rows: list[dict]) -> None: def read_all_embeddings() -> list[dict]: """Return all rows from the embeddings table as a list of dicts.""" - db = lancedb.connect(LANCE_PATH) + db = lancedb.connect(settings.lance_path) table = _get_table(db) if table is None: return [] @@ -75,7 +84,7 @@ def update_cluster_ids(chunk_cluster_map: dict[str, int]) -> None: if not chunk_cluster_map: return - db = lancedb.connect(LANCE_PATH) + db = lancedb.connect(settings.lance_path) table = _get_table(db) if table is None: return @@ -98,7 +107,6 @@ def update_cluster_ids(chunk_cluster_map: dict[str, int]) -> None: pa.field("cluster_id", pa.int32()), ] ) - import pyarrow as pa2 records = [] for _, row in df.iterrows(): records.append({ @@ -115,8 +123,8 @@ def update_cluster_ids(chunk_cluster_map: dict[str, int]) -> None: def clear_embeddings() -> None: """Drop and recreate the embeddings table, clearing all vectors.""" - db = lancedb.connect(LANCE_PATH) - if TABLE_NAME in db.table_names(): + db = lancedb.connect(settings.lance_path) + if table_exists(db, TABLE_NAME): db.drop_table(TABLE_NAME) diff --git a/backend/export.py b/backend/export.py index 3df192a..05e79ff 100644 --- a/backend/export.py +++ b/backend/export.py @@ -7,7 +7,6 @@ import re import statistics - CANVAS_SCALE = 200 # UMAP units → canvas pixels NODE_WIDTH = 200 NODE_HEIGHT = 80 diff --git a/backend/ingestion/extractor.py b/backend/ingestion/extractor.py index 7f2147a..782e70f 100644 --- a/backend/ingestion/extractor.py +++ b/backend/ingestion/extractor.py @@ -3,7 +3,7 @@ from urllib.parse import urlparse import trafilatura -from langdetect import detect, LangDetectException +from langdetect import LangDetectException, detect def extract_article(url: str, html: str) -> dict | None: @@ -18,7 +18,6 @@ def extract_article(url: str, html: str) -> dict | None: html, include_comments=False, include_tables=True, - no_fallback=False, ) if not text: return None diff --git a/backend/ingestion/search.py b/backend/ingestion/search.py index 5254960..a804c03 100644 --- a/backend/ingestion/search.py +++ b/backend/ingestion/search.py @@ -1,33 +1,21 @@ -import os import time import httpx -from dotenv import load_dotenv -from backend.claude_client import get_client, get_model - -load_dotenv() - -_BRAVE_API_KEY = os.getenv("BRAVE_API_KEY") -_MAX_ARTICLES = int(os.getenv("MAX_ARTICLES", "120")) +from backend.claude_client import complete +from backend.config import settings def generate_queries(topic: str) -> list[str]: """Use Claude to generate 6 diverse search queries for the topic.""" - response = get_client().messages.create( - model=get_model(), - max_tokens=512, - messages=[{ - "role": "user", - "content": ( - f"Generate 6 diverse search queries to comprehensively cover the topic: '{topic}'.\n" - "The queries should approach the topic from different angles: historical context, " - "current debates, key figures, recent research, criticism, and applications.\n" - "Return ONLY the queries, one per line, no numbering or extra text." - ), - }], + text = complete( + f"Generate 6 diverse search queries to comprehensively cover the topic: '{topic}'.\n" + "The queries should approach the topic from different angles: historical context, " + "current debates, key figures, recent research, criticism, and applications.\n" + "Return ONLY the queries, one per line, no numbering or extra text.", + max_tokens=4096, + effort="low", ) - text = response.content[0].text.strip() queries = [q.strip() for q in text.split("\n") if q.strip()] return queries[:6] @@ -41,7 +29,7 @@ def brave_search(query: str, count: int = 20, max_retries: int = 3) -> list[str] headers = { "Accept": "application/json", "Accept-Encoding": "gzip", - "X-Subscription-Token": _BRAVE_API_KEY, + "X-Subscription-Token": settings.brave_api_key, } params = {"q": query, "count": count, "text_decorations": "false"} @@ -81,4 +69,4 @@ def collect_urls(topic: str) -> list[str]: urls.append(url) except Exception: continue # failed query — skip, don't crash - return urls[:_MAX_ARTICLES] + return urls[:settings.max_articles] diff --git a/backend/processing/chunker.py b/backend/processing/chunker.py index 87d8104..2cd65cf 100644 --- a/backend/processing/chunker.py +++ b/backend/processing/chunker.py @@ -9,7 +9,7 @@ from __future__ import annotations import re -from typing import Iterator +from collections.abc import Iterator import tiktoken diff --git a/backend/processing/dedup.py b/backend/processing/dedup.py index a6075f5..c0f8796 100644 --- a/backend/processing/dedup.py +++ b/backend/processing/dedup.py @@ -11,7 +11,6 @@ from datasketch import MinHash, MinHashLSH - NUM_PERM = 128 THRESHOLD = 0.85 diff --git a/backend/processing/metadata.py b/backend/processing/metadata.py index aad39a2..93b8d4e 100644 --- a/backend/processing/metadata.py +++ b/backend/processing/metadata.py @@ -2,7 +2,6 @@ import trafilatura - # URL date slug patterns, most specific first _URL_DATE_PATTERNS = [ r"/(\d{4})/(\d{1,2})/(\d{1,2})", # /2024/03/15/ diff --git a/backend/synthesis/rag.py b/backend/synthesis/rag.py index f3dbadc..7b3aac9 100644 --- a/backend/synthesis/rag.py +++ b/backend/synthesis/rag.py @@ -12,11 +12,10 @@ from __future__ import annotations -import os import sqlite3 -DB_PATH = os.getenv("DB_PATH", "./data/discourse.db") -LANCE_PATH = os.getenv("LANCE_PATH", "./data/vectors") +from backend.config import settings + TABLE_NAME = "embeddings" @@ -37,8 +36,8 @@ def order_and_dedupe_by_article(rows: list[dict], ranked_chunk_ids: list[str]) - def query_corpus(query: str, db_path: str | None = None, top_k: int | None = None) -> dict: - _db_path = db_path or DB_PATH - _top_k = top_k or int(os.getenv("RAG_TOP_K", "15")) + _db_path = db_path or settings.db_path + _top_k = top_k or settings.rag_top_k # --- 1. Embed the query --- try: @@ -51,8 +50,9 @@ def query_corpus(query: str, db_path: str | None = None, top_k: int | None = Non # --- 2. Search LanceDB --- try: import lancedb - db = lancedb.connect(LANCE_PATH) - if TABLE_NAME not in db.table_names(): + db = lancedb.connect(settings.lance_path) + from backend.embedding.store import table_exists + if not table_exists(db, TABLE_NAME): return {"answer": "No corpus indexed yet. Please run an analysis first.", "citations": []} table = db.open_table(TABLE_NAME) results_df = table.search(query_vector).limit(_top_k).to_pandas() @@ -89,10 +89,8 @@ def query_corpus(query: str, db_path: str | None = None, top_k: int | None = Non return {"answer": "Could not retrieve context from the corpus.", "citations": []} # --- 5. Assemble context and call Claude --- - from backend.synthesis.prompts import ( - SYSTEM_PROMPT, RAG_USER_TEMPLATE, CONTEXT_ITEM_TEMPLATE - ) - from backend.claude_client import get_client, get_model + from backend.claude_client import complete + from backend.synthesis.prompts import CONTEXT_ITEM_TEMPLATE, RAG_USER_TEMPLATE, SYSTEM_PROMPT context_parts = [] for i, row in enumerate(deduped, start=1): @@ -111,13 +109,7 @@ def query_corpus(query: str, db_path: str | None = None, top_k: int | None = Non user_message = RAG_USER_TEMPLATE.format(context=context_str, query=query) - response = get_client().messages.create( - model=get_model(), - max_tokens=1024, - system=SYSTEM_PROMPT, - messages=[{"role": "user", "content": user_message}], - ) - answer = response.content[0].text.strip() + answer = complete(user_message, system=SYSTEM_PROMPT, max_tokens=16000, effort="medium") # --- 6. Build citations list --- citations = [ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..79eb1d2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "discourse-lens" +version = "0.2.0" +description = "Map the shape of any conversation: crawl, embed, cluster and query ~100 articles on a topic." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "anthropic>=1,<2", + "datasketch>=1.6", + "fastapi>=0.115", + "httpx>=0.27", + "lancedb>=0.20", + "langdetect>=1.0.9", + "numpy>=1.26", + "openai>=1.40", + "pandas>=2.2", + "playwright>=1.45", + "pyarrow>=15", + "pydantic-settings>=2.4", + "scikit-learn>=1.5", + "tiktoken>=0.7", + "trafilatura>=2.0", + "umap-learn>=0.5.6", + "uvicorn[standard]>=0.30", +] + +[project.optional-dependencies] +vertex = ["anthropic[vertex]>=1,<2"] + +[dependency-groups] +dev = [ + "pytest>=8", + "ruff>=0.6", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["venv", "frontend"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["E501"] # long prompt strings; formatting handles the rest diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index eec6a6f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -fastapi==0.115.0 -uvicorn[standard]==0.30.0 -playwright==1.45.0 -trafilatura==1.12.0 -tiktoken==0.7.0 -datasketch==1.6.5 -langdetect==1.0.9 -lancedb==0.9.0 -umap-learn==0.5.6 -scikit-learn==1.5.0 -anthropic==0.34.0 -# For Vertex AI support: pip install anthropic[vertex] (adds google-auth) -# google-auth>=2.0.0 -openai==1.40.0 -httpx==0.27.0 -python-dotenv==1.0.1 diff --git a/tests/test_export.py b/tests/test_export.py index 16ad692..078a0d7 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -1,6 +1,6 @@ import pytest -from backend.export import article_slug, build_canvas, build_vault_files +from backend.export import article_slug, build_canvas, build_vault_files # ── article_slug ──────────────────────────────────────────────────────────── diff --git a/tests/test_quality_fixes.py b/tests/test_quality_fixes.py index e6d66a6..e2db7e4 100644 --- a/tests/test_quality_fixes.py +++ b/tests/test_quality_fixes.py @@ -3,11 +3,11 @@ import pytest -from backend.processing.dedup import chunk_and_dedup +from backend.api.main import fetch_outliers +from backend.config import settings from backend.embedding import embed +from backend.processing.dedup import chunk_and_dedup from backend.synthesis.rag import order_and_dedupe_by_article -from backend.api.main import fetch_outliers - # ── cross-article dedup ────────────────────────────────────────────────────── @@ -47,17 +47,17 @@ def test_dedup_skips_empty_text(): # ── nomic task prefixes ────────────────────────────────────────────────────── def test_nomic_document_prefix(monkeypatch): - monkeypatch.setattr(embed, "EMBEDDING_MODE", "local") + monkeypatch.setattr(settings, "embedding_mode", "local") assert embed.prepare_texts(["hi"], "document") == ["search_document: hi"] def test_nomic_query_prefix(monkeypatch): - monkeypatch.setattr(embed, "EMBEDDING_MODE", "local") + monkeypatch.setattr(settings, "embedding_mode", "local") assert embed.prepare_texts(["hi"], "query") == ["search_query: hi"] def test_openai_has_no_prefix(monkeypatch): - monkeypatch.setattr(embed, "EMBEDDING_MODE", "openai") + monkeypatch.setattr(settings, "embedding_mode", "openai") assert embed.prepare_texts(["hi"], "query") == ["hi"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..028ff4c --- /dev/null +++ b/uv.lock @@ -0,0 +1,2451 @@ +version = 1 +revision = 5 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anthropic" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "docstring-parser" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/ed/e76e50601b7c476bcb9a2a7a4c4dab6f1a9b2c03e3922de8b3ec599ccbc5/anthropic-1.13.0.tar.gz", hash = "sha256:ad11d9bb9adafdfea26113943bcde9973e2a439ebeeda6c89ff3e3d85bb2f5c1", size = 1567362, upload-time = "2026-10-09T15:29:08.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/9a/a835340c91bc9a233bd54c05fc438fe07cbef71ca667f24f3892a268ae20/anthropic-1.13.0-py3-none-any.whl", hash = "sha256:157bd74dbf6a595cf9e5fcac557c791fd297924dc48261f57470c2ed6f4e6671", size = 1958595, upload-time = "2026-10-09T15:29:10.689Z" }, +] + +[package.optional-dependencies] +vertex = [ + { name = "google-auth", extra = ["requests"] }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "courlan" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "tld" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/16/2a771612ee0b3acaa95ac21cc7e8a3319e815d6360f8ffc5987d1ce28499/courlan-1.4.0.tar.gz", hash = "sha256:fbbac7b7fcde2195ea08e707609503c81cf39c891e8d26cdb1fed4585782d63d", size = 208997, upload-time = "2026-06-01T17:30:17.306Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/38/ce65091ff20a16e06d17418c4353af5f56d3190821b1a06983c79ae79274/courlan-1.4.0-py3-none-any.whl", hash = "sha256:ad1dbdefd912ca7238d4607dc855df5df097f56bac175dd662c84eed3802f49e", size = 34193, upload-time = "2026-06-01T17:30:14.984Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[package]] +name = "datasketch" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/ae/bbcdeab67ebfe27747896618bca0f8113e79d534c5af2b06ce6693c981de/datasketch-2.0.0.tar.gz", hash = "sha256:e0570e170f7e64b8d6fb1cc2e4ce36a9f7036c5100167e50a0770addc50558c2", size = 98666, upload-time = "2026-07-05T06:36:30.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/df/9e79bf1fb2f660507dbd00d24730d107595a2f599f2524a96344149c95f1/datasketch-2.0.0-py3-none-any.whl", hash = "sha256:aea5ffafcce776e03d085740e78b874e778d779b07ee11ca636ca51b3fef09ed", size = 107247, upload-time = "2026-07-05T06:36:29.222Z" }, +] + +[[package]] +name = "dateparser" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/5d/bd21ba1519b6b1e222b29878301d2e1fb928e890dc7d085fa4222ac5671b/dateparser-1.4.3.tar.gz", hash = "sha256:bab8c43a746266e68142f4926e69438ce551441aa88e54e78bb6410bf3ee7000", size = 362152, upload-time = "2026-09-03T10:07:54.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/5f/63f927b5ffd1cc2c030fc2bd362bf920890e8806cf42f701198de5b3f9c7/dateparser-1.4.3-py3-none-any.whl", hash = "sha256:cbce86c64e0cea5c54c84c015d34c903d46c51bfad50632bf2f05a6141d10c05", size = 322384, upload-time = "2026-09-03T10:07:52.913Z" }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + +[[package]] +name = "discourse-lens" +version = "0.2.0" +source = { virtual = "." } +dependencies = [ + { name = "anthropic" }, + { name = "datasketch" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "lancedb" }, + { name = "langdetect" }, + { name = "numpy" }, + { name = "openai" }, + { name = "pandas" }, + { name = "playwright" }, + { name = "pyarrow" }, + { name = "pydantic-settings" }, + { name = "scikit-learn" }, + { name = "tiktoken" }, + { name = "trafilatura" }, + { name = "umap-learn" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +vertex = [ + { name = "anthropic", extra = ["vertex"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=1,<2" }, + { name = "anthropic", extras = ["vertex"], marker = "extra == 'vertex'", specifier = ">=1,<2" }, + { name = "datasketch", specifier = ">=1.6" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "lancedb", specifier = ">=0.20" }, + { name = "langdetect", specifier = ">=1.0.9" }, + { name = "numpy", specifier = ">=1.26" }, + { name = "openai", specifier = ">=1.40" }, + { name = "pandas", specifier = ">=2.2" }, + { name = "playwright", specifier = ">=1.45" }, + { name = "pyarrow", specifier = ">=15" }, + { name = "pydantic-settings", specifier = ">=2.4" }, + { name = "scikit-learn", specifier = ">=1.5" }, + { name = "tiktoken", specifier = ">=0.7" }, + { name = "trafilatura", specifier = ">=2.0" }, + { name = "umap-learn", specifier = ">=0.5.6" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, +] +provides-extras = ["vertex"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "fastapi" +version = "0.143.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" }, +] + +[[package]] +name = "google-auth" +version = "2.61.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/0b/9788e913f2202da49068c27ce821eebcf96319240a89d7bb11f206d6471f/google_auth-2.61.0.tar.gz", hash = "sha256:37f0815967322e8c32b12bf422531e8b637cafdaae0acbb9141117cfe6a96f23", size = 398379, upload-time = "2026-10-07T20:13:14.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/68/c2ff6043d93fda69e5c12a834d0be3b3fa6c762e89732160532566be621b/google_auth-2.61.0-py3-none-any.whl", hash = "sha256:ca60266a37475ae68b63bac007272f46094b3d571abe92aded329b6cfb568025", size = 268021, upload-time = "2026-10-07T20:13:13.2Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "htmldate" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "dateparser" }, + { name = "lxml" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/4d/3303caa82c75cd81e43d81906fa0f57c02c1e2e92ef9a3709cfe57fa678a/htmldate-1.11.0.tar.gz", hash = "sha256:e099a58143d5ff32471f2aa8770247e12f0f5bdff62f2df74f833277fd0071b6", size = 52776, upload-time = "2026-10-02T14:24:47.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/f8/6139fc95b889f44e9a4b373778f10315ade81b395a6298adf3c08415de64/htmldate-1.11.0-py3-none-any.whl", hash = "sha256:2a5a0d80cce38d3f986a64bb9ab64ba03e5953159b38fd78a841492131ead348", size = 33365, upload-time = "2026-10-02T14:24:45.42Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httptools" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" }, + { url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" }, + { url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" }, + { url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" }, + { url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" }, + { url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" }, + { url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" }, + { url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" }, + { url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" }, + { url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" }, + { url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" }, + { url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" }, + { url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" }, + { url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" }, + { url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" }, + { url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" }, + { url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" }, + { url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" }, + { url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" }, + { url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" }, + { url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" }, + { url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" }, + { url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" }, + { url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" }, + { url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" }, + { url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" }, + { url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" }, + { url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" }, + { url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" }, + { url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" }, + { url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" }, + { url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jiter" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" }, + { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2c4a8075ed5ea02b56911e9375d4c8d7784572ff4af32e5a99ae0d071044/jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa", size = 290991, upload-time = "2026-09-12T15:12:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b1/34bfa29599d420423baac6ff7cada6674fe63d5a7a2ccb3900b904678783/jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3", size = 321425, upload-time = "2026-09-12T15:12:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/a5ac64a62a04aebd556afadab14a6b730001e16df87266ded943a100a1d9/jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b", size = 343138, upload-time = "2026-09-12T15:12:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/f761e320ea473314cb68612bc6a435393464dbd198051399b36848b4ebf3/jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b", size = 325805, upload-time = "2026-09-12T15:12:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/27d8e40f0fb29bbc7a5adf30907144396a115dbe93d5d8976c054a6dfe96/jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a", size = 340230, upload-time = "2026-09-12T15:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/30bcde78a28155461f965d16b7aca4ffca6d17494d905f7a0bb072e6c64e/jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807", size = 351343, upload-time = "2026-09-12T15:12:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/91420b156315ae22732f5ee1a7b5725a030aab9dc8fd7dcdacfb4aa588d3/jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5", size = 344990, upload-time = "2026-09-12T15:12:20.705Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/ae6d5672644cc11127970277c9aeb0fa6fae376845587f5b0a8e8828167c/jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca", size = 328624, upload-time = "2026-09-12T15:12:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/45cb1162f6aa586536e4a973fc339d72dc6b08cca030d70a838a307aa778/jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8", size = 334731, upload-time = "2026-09-12T15:12:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/45c1574b644da7deda0b7591c349520dcf83ce45b24d7ca19922dab1fc27/jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9", size = 483649, upload-time = "2026-09-12T15:12:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d3/ebea1ecb5b241c519f192b30215c79a8e47f42f1621acbcd6f8830728416/jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8", size = 520758, upload-time = "2026-09-12T15:12:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/682b641ff0765ea9bdc349dbc7d223de5c8af8ec1abda0db3406992f92fe/jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7", size = 184334, upload-time = "2026-09-12T15:12:30.813Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d2/9a49aac2b27af4cc5015e368c0cc3588491a532f717a668ffce1f1ac57da/jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd", size = 226601, upload-time = "2026-09-12T15:12:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ce/9a43e9f614608eafa78de22aedcff54cd21324467b5d442d5c9b00244145/jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac", size = 183103, upload-time = "2026-09-12T15:12:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, +] + +[[package]] +name = "joblib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/1d/537ab090f302b838943a1b56497dd53059b9a9b46a074936470173a2e207/joblib-1.6.0.tar.gz", hash = "sha256:2ccc96785b12046c08fd6d55839c12857831b54a3c1673ffadd2f04bfc4eda03", size = 327903, upload-time = "2026-08-31T09:39:04.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl", hash = "sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba", size = 306115, upload-time = "2026-08-31T09:39:02.298Z" }, +] + +[[package]] +name = "justext" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml", extra = ["html-clean"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/f3/45890c1b314f0d04e19c1c83d534e611513150939a7cf039664d9ab1e649/justext-3.0.2.tar.gz", hash = "sha256:13496a450c44c4cd5b5a75a5efcd9996066d2a189794ea99a49949685a0beb05", size = 828521, upload-time = "2025-02-25T20:21:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/ac/52f4e86d1924a7fc05af3aeb34488570eccc39b4af90530dd6acecdf16b5/justext-3.0.2-py2.py3-none-any.whl", hash = "sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7", size = 837940, upload-time = "2025-02-25T20:21:44.179Z" }, +] + +[[package]] +name = "lance-namespace" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lance-namespace-urllib3-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/e7/d5d46594678ee479c0eda830c47b2f5c46133bd100e84e9aa6e01306eca7/lance_namespace-0.13.0.tar.gz", hash = "sha256:24554a0997bdb39595c6e4cb3ac6722069f6cd3bd1a74e7acbba7bfaf774a40d", size = 11636, upload-time = "2026-09-14T02:47:03.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/47/cfa33cca1ba7c749fd2918cfc5c8ded788f378cc6424d23e4fead5a14125/lance_namespace-0.13.0-py3-none-any.whl", hash = "sha256:438c7b17aef421c21c138196e715f2510d62f07e865372047f87c1e75e618c7a", size = 13507, upload-time = "2026-09-14T02:47:01.765Z" }, +] + +[[package]] +name = "lance-namespace-urllib3-client" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/25/9aaa4a5e8999693fb0f227c2c0c4b97bd8f0539066408cdff0b89d41b2d5/lance_namespace_urllib3_client-0.13.0.tar.gz", hash = "sha256:1e8a79c6e4e6277033597fd76aa0e2f33d909ca1436c935936e9e569221f43ef", size = 238348, upload-time = "2026-09-14T02:47:04.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/4c/7b8f0712a7fe1b8655b711342bc4989696635a79e7027ee56ba3cae23e99/lance_namespace_urllib3_client-0.13.0-py3-none-any.whl", hash = "sha256:fb361eb4f6c7f2d1f9e92809609657b73e38241393e9c4516e12d6b6ee643a0a", size = 406848, upload-time = "2026-09-14T02:47:02.715Z" }, +] + +[[package]] +name = "lancedb" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "lance-namespace" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/2b/855ab90aea9cfd311842be596ca12dcc366df7b2e8209003f95cc8079f6d/lancedb-0.40.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:10e6fbacc9a9be5698c8e635f150ef4346e428db71d15b31bc1b79aec2a382ff", size = 67454352, upload-time = "2026-10-07T10:35:02.537Z" }, + { url = "https://files.pythonhosted.org/packages/a1/07/bcdd8f581db0719a5e99be5abdf2a569c840f9b3b90069eff1181141b291/lancedb-0.40.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e967577fe42980217e43f9b6ecbe042c5ae314370a34b88f1c54e825f96b26f0", size = 71651477, upload-time = "2026-10-07T10:35:06.644Z" }, + { url = "https://files.pythonhosted.org/packages/82/f7/4a5b7bff8abf486d4dc43fc1cb06c5c08472a1aee760eb5d9d10bd7c770e/lancedb-0.40.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:aac9e08a710ba2071a8aefc4b4ef7d8534f5f7e4e4ce1761f11469d97c36f1e2", size = 75231099, upload-time = "2026-10-07T10:35:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/88/38/00ed271fd7fc51761b7d449856913a64951041881e68972602643eae7349/lancedb-0.40.0-cp310-abi3-win_amd64.whl", hash = "sha256:aaea68920b88e3d0b84a9ec84bc1585ad04239b9ca1bfcd1e491c2c12bffddc2", size = 83389723, upload-time = "2026-10-07T10:35:14.427Z" }, +] + +[[package]] +name = "langdetect" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" } + +[[package]] +name = "llvmlite" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/c5/907cec40688a34eb489cded74d555e1ee4af8cf49d83e03dba2c2d4cfe27/llvmlite-0.50.0.tar.gz", hash = "sha256:f2a2cd6ec9ffcc1b7147dea0d7a49efebf17a2b434e0c2844fe175999d571eb4", size = 194522, upload-time = "2026-09-29T18:44:46.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/1f/2576416b3e9b73f77b8331b7f2e41ce5ae7bbff0489eb16d98099a71693c/llvmlite-0.50.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:55f50a6b7c0b8de88b05d6bc407d70a60486ce024013997dc97e202bd187c75b", size = 40534277, upload-time = "2026-09-29T18:42:56.244Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c4/e86f30b2b09c310c02ffdd8afd00f7e127d365131d163c926c98fc3ece22/llvmlite-0.50.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e8df54380110ea5e9127386e739d2b0829cc6dfa4a24a9195226336c91b06d5", size = 58344485, upload-time = "2026-09-29T18:43:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/4c/72/22b6449e15bec4cc86c62b659e6c625ab777d01e87aaec717ecef440f87a/llvmlite-0.50.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d501e5103076b9a14be885d2574dc2f6793171aa54a853d1244e011d476f1399", size = 59696588, upload-time = "2026-09-29T18:43:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/64/70/f395702c20b514363061055b5bdebe3513e544139e6d412a5c86e8ea0b30/llvmlite-0.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:c20595cc3a76e3c85140fdafbf9246c732ddf8e0e646ba2f4e4881f87567300d", size = 41865553, upload-time = "2026-09-29T18:43:08.29Z" }, + { url = "https://files.pythonhosted.org/packages/a6/86/9cde7ac29e183e994dd2d67c998752c66ff6d714ca61837428e1896c3cc9/llvmlite-0.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:4b78a8b669eda09ca1ff4c1a75003023912092974d3e771d1da0777f1b383bdf", size = 37441845, upload-time = "2026-09-29T18:43:12.054Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1f/1d585b2122bcc9fe1615c0097730baebdef1b80e6acd07fe921ee501576b/llvmlite-0.50.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a32980e3d727b0e56974ad89d0764920048602a75805b8917cc0298e798b0ced", size = 40534276, upload-time = "2026-09-29T18:43:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/21/3e/d5dbbc80bd87c3530bae1127cefce56b36434cc8a7fbbac281309e2af435/llvmlite-0.50.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dde9836d144c446a303b57b2dd906c35308411eb07f1279c1db581d3d774048", size = 58344486, upload-time = "2026-09-29T18:43:20.663Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c2/5e9d0773f1589397a3ea3dcfa4bbee36e2855ad938d738dd6ff9f505a59b/llvmlite-0.50.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:425845f415a06dc50db08db033c6b568e0d85c4937e932c605a4d49e1514b2da", size = 59696589, upload-time = "2026-09-29T18:43:25.605Z" }, + { url = "https://files.pythonhosted.org/packages/d5/17/894321d44cf94fa5cf921eff4e7ff24c7732c3d702236d40d6055b68a693/llvmlite-0.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:266a6a29be71c3e3a22960ddcedf66b4e0388e5abb6cc4991cc093d6df402ad7", size = 41865552, upload-time = "2026-09-29T18:43:29.755Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d7/c3c3a70f057c18313515af3bd970c1faa348121e2545d6074f22011feca9/llvmlite-0.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:1cb21c420a47dcfa56223228d013c6f9d234e05e06e6819a41638d78bbd78e6c", size = 37441843, upload-time = "2026-09-29T18:43:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/b8/08/eecfccb51bc016de4c1fb69da815738076a186158fa61d3cae1458b8f44a/llvmlite-0.50.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:ecdc9fae295da8ac793578a27020515e24d970513143efa227e696582aeb16e6", size = 40534277, upload-time = "2026-09-29T18:43:37.013Z" }, + { url = "https://files.pythonhosted.org/packages/9a/96/011ae57fb82e326a79da1c4767b8206502dbac041068b37f1fbe73893a55/llvmlite-0.50.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:987600ce6f7bd6d808f4bb0ea61a8eff2fd17cf32355691e801eb0a65a7304f0", size = 58344485, upload-time = "2026-09-29T18:43:41.242Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ed/54107648386edf3da7def03d42721c72279f6bc2e17b5274c18955dc5833/llvmlite-0.50.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33ddf12b1e12d7e551e1c1e6ca8087d0aacc931f480019eb33ef2ab77681da4d", size = 59696587, upload-time = "2026-09-29T18:43:46.132Z" }, + { url = "https://files.pythonhosted.org/packages/d1/af/b2e5f9ee84f05a794e62626d83a934e6fccc7a83740918a90cec85df2d6f/llvmlite-0.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:7ae211012c6849528a5f7cd17a78d8b2421a2813c7b4184d6c0b2ffa89a7d296", size = 42986708, upload-time = "2026-09-29T18:43:51.123Z" }, + { url = "https://files.pythonhosted.org/packages/3b/df/6d9ac4237f78bc81e6778d87ec711c6e5ec0fac73f00907b149c414b48b5/llvmlite-0.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:e94f9066f1257a9cef6c832e6c9de0f140e2bb150de2db39f657b2a5996e0f6b", size = 37441844, upload-time = "2026-09-29T18:43:55.097Z" }, + { url = "https://files.pythonhosted.org/packages/d6/23/0f9d73a3603fee0d32a0f66996e00964154f07681c0b0f9c7212e896cb2d/llvmlite-0.50.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:423c8d89d13f7eb4488933d5a86b0fa952927956298cfd0087f6753b5123b5df", size = 40534276, upload-time = "2026-09-29T18:43:59.379Z" }, + { url = "https://files.pythonhosted.org/packages/34/14/45f56e4cf192284ba6cb3020ed775d47dd9c69e7fb605f7523047ab16d7f/llvmlite-0.50.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:944133e9621d1dfbfdaf0fed3234b99f85e6ba27c38f4045acc8f8a5e699a5c0", size = 58344486, upload-time = "2026-09-29T18:44:03.923Z" }, + { url = "https://files.pythonhosted.org/packages/82/f8/45f08fe27bd96fa38a7199024d842d6ef502054f1f824b531d55cd533c81/llvmlite-0.50.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d5b6eac064f201b4aa091030282e6f240d8d322dddd7381840731455c3e664", size = 59696589, upload-time = "2026-09-29T18:44:09.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/68/e00620b48cd6fd71369877ddbfa000854450b843c3631be41226e8b8f7b1/llvmlite-0.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d88c9b325f5fbefc79d95b1daa8fb96018c40bd2958103eea7334e6c8f17fb40", size = 42986716, upload-time = "2026-09-29T18:44:13.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/97/78e51381def071781a5ec9ead92e2a55562da5b78043566865e20f30be77/llvmlite-0.50.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:3f490c0f4800c8ddeee6a607acd037497bf6508586804f4e2f11f53a1ee7fe2d", size = 40534277, upload-time = "2026-09-29T18:44:17.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/1beb6169126cd1a8199bae88eb3a79e3be3dd609eb42896d8fa8c38b10c0/llvmlite-0.50.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d5447a6c39171368edfe28a71f605e6e3edd40a1dc31f5e5c9d50585718ae6d0", size = 58344486, upload-time = "2026-09-29T18:44:21.407Z" }, + { url = "https://files.pythonhosted.org/packages/7e/81/334b11c9ebc52ee5339fe401342b2dc856804996fec3abc5ad70ad053901/llvmlite-0.50.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1ac2b9f699c46219fbbd66b304105f5e1b218f05ffac6fe03cd851f93718e58", size = 59696588, upload-time = "2026-09-29T18:44:25.755Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/f06fe5d262f0cf0f0c85a85b0a4aaa07cbd85a56192861299fd659af4eb7/llvmlite-0.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:51a4a716db98591f0a1bea34c6548cdb4017731ee5e678ded8cf842dca8af3c5", size = 42986709, upload-time = "2026-09-29T18:44:29.203Z" }, + { url = "https://files.pythonhosted.org/packages/be/f9/670bcb2a7214dcf35c48da581ac8d2949ff50255deb83e13c9cbbef46c05/llvmlite-0.50.0-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:e8cc203c1fd509131cd72b7554413d4a3e5527cc5558c5a7ebe19840018c57c1", size = 40534277, upload-time = "2026-09-29T18:44:32.967Z" }, + { url = "https://files.pythonhosted.org/packages/f3/21/3d108d6c9a87142927073fbc3d82d161f2dbfdeb046063a51edb196d1132/llvmlite-0.50.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7d4e2bbb29a860a6e85e22afdb96696241263942a5b214cac3e4b704e1d3abf", size = 58344488, upload-time = "2026-09-29T18:44:36.859Z" }, + { url = "https://files.pythonhosted.org/packages/6e/de/496d19b7a54acc487266ac7fa39d902cddf24998f5266b3aa499c8eacbd6/llvmlite-0.50.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:afd7b438c60e0f60c4368ec603bb9f20d938a203b5f59b80bbe50c749b4b2f16", size = 59696591, upload-time = "2026-09-29T18:44:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/93/73/72553170eada174775d9a738c471c7be4ab3dc2c06368beeee89e002345c/llvmlite-0.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:4da0e8c6e6f144b433672a632f75d6b4da7bd4fdb5c3e9981d6ea6741319aeae", size = 42986722, upload-time = "2026-09-29T18:44:44.491Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" }, + { url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" }, + { url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" }, + { url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" }, + { url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" }, + { url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" }, + { url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" }, + { url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" }, + { url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" }, + { url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" }, + { url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" }, + { url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" }, + { url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" }, + { url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" }, + { url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" }, + { url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" }, + { url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" }, + { url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" }, + { url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" }, + { url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" }, + { url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" }, + { url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" }, +] + +[package.optional-dependencies] +html-clean = [ + { name = "lxml-html-clean" }, +] + +[[package]] +name = "lxml-html-clean" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/63/195dfdde380a84df309e3bccf4384b034b745dba43426886f7ae623b4fba/lxml_html_clean-0.4.5.tar.gz", hash = "sha256:e2a4c7d5beedd17cd7b484d848a0571e54baa239a4f9df5546e3acba7f990560", size = 24142, upload-time = "2026-05-20T12:17:53.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/bd/6e2b76a6c5dee10397db9c929f0c5066766ec1036046f0335b7ca7ca08b8/lxml_html_clean-0.4.5-py3-none-any.whl", hash = "sha256:c76fcadd1e5bfb9b8bafc2200d51e4e78eb0dad67f56881c21dfb6484c7e7746", size = 14573, upload-time = "2026-05-20T12:17:52.215Z" }, +] + +[[package]] +name = "narwhals" +version = "2.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/21/f64d6b2dbea7bf3f8c38cdc786dcc6ef012ca3d173ad208c782c9a7bedf6/narwhals-2.27.1.tar.gz", hash = "sha256:aed93076a3ea42d9c32c88e4eb5ea422a21937011cbe1f480f9572a523c82094", size = 735013, upload-time = "2026-10-10T06:52:18.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl", hash = "sha256:d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31", size = 483211, upload-time = "2026-10-10T06:52:16.32Z" }, +] + +[[package]] +name = "numba" +version = "0.68.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/cd/e8280f9ffa30fea9fabc5341223701231fcc5d53a31f51419d42d4bec3a6/numba-0.68.0.tar.gz", hash = "sha256:8a781de54b980b98f43bff7f1093701b5f07c80d031c7cfa8a87493d8bf73f2d", size = 2855363, upload-time = "2026-09-30T15:05:44.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/cb/b6a39189f1f342baa04ad1055bb5f63ec4061ec1f80f6b34e90c68fe1e7f/numba-0.68.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:0fdaa2f0256862ebbcd9632ef01ba2a4b94e6d116029e5051a92340d4050a501", size = 2760509, upload-time = "2026-09-30T15:04:53.181Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/aa2cefeef784c5695790931938944f76ee66d3c7c640f62326f64642f1c6/numba-0.68.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3ee1f49b62efbbb804f731f2bd602bd1f8b8d3cc13009f25d69955675f82407", size = 3600404, upload-time = "2026-09-30T15:04:55.11Z" }, + { url = "https://files.pythonhosted.org/packages/6f/40/2211b4ff48cccfb21d4c38fb56788d7a975189883efb8d549be9d51aba7d/numba-0.68.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fe913a70fe9a7a0b193757ff977a9e96c82ae936ae388aec8990814fffdf9d", size = 3888027, upload-time = "2026-09-30T15:04:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/7e/2b/1b1f8b118cec28513665d8a53ff4f037d6c05720bd9e6f32f947c93c367f/numba-0.68.0-cp312-cp312-win_amd64.whl", hash = "sha256:530961dc7e41ee358eca2b828baf7b645ce6fa466d778bb9dc73855dd103c4f7", size = 2830891, upload-time = "2026-09-30T15:04:59.747Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/02626d27333ce1f67516a059e22d65f8f2309f227d3b828d2599183d5dc9/numba-0.68.0-cp312-cp312-win_arm64.whl", hash = "sha256:25aa7021e163701f9b3e8e77be81836a4b399500eef073d75bc906ad5eff46e9", size = 2812331, upload-time = "2026-09-30T15:05:01.802Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4d/42754c94f8f909b9981fd44d28292a93bca6429d93f3e1ae58ac7de9b08b/numba-0.68.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:b8b29602f57df06c724fc53b1740887bc4332f202206771d46e47b25b485e904", size = 2760360, upload-time = "2026-09-30T15:05:04.386Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1c/8bae32109a826a49666a9645012b98d6e09ad496932a877c97a2c39dde50/numba-0.68.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df6f881c5695f472873d0979bab54261959b3174b6c98a71f6f8a43c3e088985", size = 3560908, upload-time = "2026-09-30T15:05:06.832Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/0b504ae34d1b79a6482a0ffcbfd1b103dde02329c11525033e02633f7984/numba-0.68.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be647fbc60c18c0323b34479f80173879654894eec58ad061f4b1901e294d854", size = 3848615, upload-time = "2026-09-30T15:05:08.976Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/06d1dd4553dcc71a3a18defe9e6e26e3c011b566bc9060d4f6e4bca0e0ed/numba-0.68.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf7435c81912e271a28a19c348ada5b3986e2409f95a067533c5f4aab8709295", size = 2830730, upload-time = "2026-09-30T15:05:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/93/d8/6b01de5fa7b4c3866c0fb680833fd58b4fc48d1e7febb46e992f0b0f0e7b/numba-0.68.0-cp313-cp313-win_arm64.whl", hash = "sha256:50e3c81d8bf6956c7d7330a985bf1468efaa9e4c4539c9fa0ac6c7866ea6e369", size = 2812090, upload-time = "2026-09-30T15:05:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/71/a9031907dd0fba6cfce34004398a05f090b692be811dd1f38fdd874dd4e1/numba-0.68.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bfc890c9ca517823dfae0444595ef50d883ade9d3e17759d9a7650e5d128d950", size = 2760551, upload-time = "2026-09-30T15:05:15.753Z" }, + { url = "https://files.pythonhosted.org/packages/74/70/c03aebc576ded2204e5bde9b86b215f0590a81261af333d4239b9f0aed0f/numba-0.68.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ccf54fd9c1d5f4ba00073b81bc492a681f5437c62917fe29813f457564e312", size = 3561561, upload-time = "2026-09-30T15:05:18.266Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5f/2bd2fd4b99b0b5e76fea2f1fe149e05a7ec19a9a177758688bb82c7e3126/numba-0.68.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea11c865265e39a6019e2f0fe62743825127b3b7bc4815916f5d5121fd9b262b", size = 3848766, upload-time = "2026-09-30T15:05:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/0c/41/3e3528f3b0f9ffae69310d2e71f81ff74d272ee3b6c0600c4f4abaa31a80/numba-0.68.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c03de7085f08ba11ab2444f252e822c14cee5fa02b73e84d5afd5e28b2bce0f", size = 2832584, upload-time = "2026-09-30T15:05:22.621Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9d/1fe8be8f3a43d339222a4aed59be0b8f4920f10465d4606c0428250c63f7/numba-0.68.0-cp314-cp314-win_arm64.whl", hash = "sha256:f58c13a6e9bfef062311cb0d3c19f6c159b901213daa325e1db473946010cec7", size = 2812334, upload-time = "2026-09-30T15:05:24.848Z" }, + { url = "https://files.pythonhosted.org/packages/89/3b/e0e31617568553ca2b18bdf43844c44893dfb6620bde9a88296c257c5a81/numba-0.68.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:79160dc2a3ff0e02aaada2c385faa6de73d71a11f06419d29bb0a90042d243a3", size = 2763380, upload-time = "2026-09-30T15:05:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/405b416800424b005c179c5b6417eee2aac1933839257ca50c855397774f/numba-0.68.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a3aa5558ba1c316020a0c2f6042be6ae063cfc6eb0c7badb3a0c77d2b5308b7", size = 3604721, upload-time = "2026-09-30T15:05:29.164Z" }, + { url = "https://files.pythonhosted.org/packages/e1/52/fc100dc163e12ba6a8df4c4f6e34f55d24dc6e97095f935996406d8cc946/numba-0.68.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a08750c81fd5c2d9f2c169a73114efb907159401dde9ef4a3b629fa45e097cb7", size = 3887891, upload-time = "2026-09-30T15:05:31.234Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/f2e074c5bf26f236c34075d390e77ed2a787c7350791b39b099b151e2033/numba-0.68.0-cp314-cp314t-win_amd64.whl", hash = "sha256:cad7d5f6fe8eb42a69c500d36c94a61d094f3b91a7a5581a31d1df2eb925d33a", size = 2838113, upload-time = "2026-09-30T15:05:33.274Z" }, + { url = "https://files.pythonhosted.org/packages/a5/85/d7cee7a6c65634bd25cb0109585785e5c8338f44db4b191c30291d9c7968/numba-0.68.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:39f935bc854be87784675d9674f5503e56df5a501c95c95bdfb6b3c0b4b9ed1b", size = 2760868, upload-time = "2026-09-30T15:05:35.662Z" }, + { url = "https://files.pythonhosted.org/packages/d6/79/312e0cf6e835f700d42a223c1bd4a24b232892bded1ddf5e40bb3a329f55/numba-0.68.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cec6809fe93824e243a8a8c93966b0bb5874a3b7c24c1194c3bafee0ab11f39", size = 3568127, upload-time = "2026-09-30T15:05:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/5e/05/f31cd9e40f6d4ec6de38959e4736a917aa9d115fecc4a1979aceedcc083b/numba-0.68.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f1180e0332ad5143905288325485b52ac76102330811dc6f2c10088cf4cedc", size = 3853913, upload-time = "2026-09-30T15:05:40.247Z" }, + { url = "https://files.pythonhosted.org/packages/6c/28/059b2d1ea5616a5712fd722b2ec8e8278d14e4e4eb8845d36fe1658e6be8/numba-0.68.0-cp315-cp315-win_amd64.whl", hash = "sha256:a2d21bb9c4b4818a1e71721ebd19172f488591d548f08453593348b7048ba1fb", size = 2831865, upload-time = "2026-09-30T15:05:42.306Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, + { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" }, + { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" }, + { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" }, + { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, +] + +[[package]] +name = "openai" +version = "3.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/a3/3b5576af9c4b8cb7eef1383d33c48a9431b292983fe4b78cbffb31d0aeeb/openai-3.28.0.tar.gz", hash = "sha256:cf0509d32d7bd8eb6a3db02417957cb455212b6629d071826b9ea1f61659e2a4", size = 1827445, upload-time = "2026-10-09T20:50:14.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/4a/5c807ade1a86ab8accad20dc993ca7de5d1f7b2e78d4674c300c2e6a47b9/openai-3.28.0-py3-none-any.whl", hash = "sha256:1ea40a084ffe2f9f16fd3bea6e870f757853b536b18d8e737c518ef849a2920c", size = 2171532, upload-time = "2026-10-09T20:50:12.282Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/4c/597d588c055d4373cff19cbbc32d4dd046c7be8fadee957585b5ba9e5b24/pandas-3.0.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dac2d65e9087e8e7b5a45fe15c4920911a221df061ab629943ce016489145c7", size = 10397411, upload-time = "2026-09-17T23:20:49.465Z" }, + { url = "https://files.pythonhosted.org/packages/18/8f/48907c7c707b61a8e5018c32e1a3f70623209bfb59020a2f6196159d3aa7/pandas-3.0.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9dab635a549e58a053c7b0fa054dc0bd7be22f0ed9a720f4a85d5fb993276172", size = 10051279, upload-time = "2026-09-17T23:20:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/67/fa/613d867c3d9554a61bafdec6f79565c8a3e73235feb52cc4a72ad2e0fa6a/pandas-3.0.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3dccb584123b399c07562ac4d62543e90ede49ddf8ce3c13ffc64cbe828c281", size = 10303429, upload-time = "2026-09-17T23:20:55.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/67/0c0f18e38d7f2d2af8c24b3315bc4046e73bbdd4a5540405506671ad0c0d/pandas-3.0.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0704044b676496b8350e023b09f174a26772456c974a2b11c36bebb558c9490d", size = 10788193, upload-time = "2026-09-17T23:20:58.617Z" }, + { url = "https://files.pythonhosted.org/packages/39/53/1b57f3162501fe36687e4870e1b918a6458ca7386b173af75663ace95857/pandas-3.0.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e7c1905ef02c3d6d43d9dbd5b6ccb4da4870a0b0c821bbc103fbdb6f3ad2707b", size = 11323501, upload-time = "2026-09-17T23:21:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d2/b1182e8d39100369d7f13f4a125a3fb6b096fef112c46d0566c25781ff68/pandas-3.0.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:569e114072b24fc4970c12e2b4bab252671668a40b324318903380cab0254c0c", size = 11838173, upload-time = "2026-09-17T23:21:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5b717af24d2f27995e51e0875a269dddd373045216e34cf62e3aa764eaa1/pandas-3.0.6-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:2a8fc94be2ee5f1d86f97aacd8cc566f81680b6498e76f3007421bb5d98151bf", size = 7118499, upload-time = "2026-09-17T23:21:07.661Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/14b3b17cd75cef4a1ee1af4234eb41cc1afe4103b98c2d80e8916abfd42b/pandas-3.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:3ef908d28590b3f42d7070e7ad8f9b34b442b260b7f3c1afb57e0040c58cdb1b", size = 9661439, upload-time = "2026-09-17T23:21:10.959Z" }, + { url = "https://files.pythonhosted.org/packages/3b/11/3d580a604a1e35d69f6676847bd12db7d14344bc677b717f4413e79c5d0d/pandas-3.0.6-cp312-cp312-win_arm64.whl", hash = "sha256:f4e7c52eb108d752e7592268108fd3e98efd76d83a3125cdd06c621c2e44359b", size = 8959806, upload-time = "2026-09-17T23:21:13.851Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/143605a1f6443ad50ebda78a31e5a3a10147fec2590e931584aaa5ff0a09/pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ae8073aed8e21d1a7fe263dcdc6840743549722a6738198a0a46000fa9476f2", size = 10418900, upload-time = "2026-09-17T23:21:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ca/87f8548f73d452aab35e4a90f8b39ae303295e0f2ef0b4055c44d6b3f1be/pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:60d81f9e1799b36f3739e7fff44d1fbb2e8fd5a271b3863e03de9715fccda0fa", size = 10064785, upload-time = "2026-09-17T23:21:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/d951442e5607c6e3b2462eff8f420797d428aa74b87c6ecfe4f48553626e/pandas-3.0.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:097090508a1dd335013d39106fc10b20f4fd4a171638e47b77d55798ed9dab6c", size = 10245290, upload-time = "2026-09-17T23:21:22.797Z" }, + { url = "https://files.pythonhosted.org/packages/50/fa/96d50e1e6cd0b08b5e2b7c838f65ae644940f75a124063380b5ef73b6866/pandas-3.0.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e92d9fa834c7d877130027cddc0cad8dcff97c1f6cca26bd6310f847228b658", size = 10757657, upload-time = "2026-09-17T23:21:25.673Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/f82d13a2cb703e1a8acee7e01fdc2b898d9cd0c00f07d1dfce63af43e350/pandas-3.0.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b27c8d890e4aa2171437ae2a39de1d215e674158e4865c4023a8b31c932513b2", size = 11249114, upload-time = "2026-09-17T23:21:28.898Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ce/8aef2e561a2f2c8b38c913c67373c65ba6748174e763d27c80271b24bd17/pandas-3.0.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8029ec0f1f89e4f985929ce1f6626dabf3140d61a4e9c1215afdab34eaf9a5d", size = 11820511, upload-time = "2026-09-17T23:21:32.11Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bd/63cb67e6903ef6d9c2871916dbcbc09d254da0fe8b870cf62e16b21945f2/pandas-3.0.6-cp313-cp313-win_amd64.whl", hash = "sha256:f3ce8a6968045481e91a3990e797e348ce13db45ee164a7095bbc824e26c09dd", size = 9638092, upload-time = "2026-09-17T23:21:34.883Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/e7b35b712edb068d382ddc8b2bea8a04974100515ba2daa22b478b265842/pandas-3.0.6-cp313-cp313-win_arm64.whl", hash = "sha256:cc39303913e2ea129915670de5d1c9fbd647f543bb72e5543bac8baa94e9e42f", size = 8952032, upload-time = "2026-09-17T23:21:37.729Z" }, + { url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" }, + { url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" }, + { url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" }, + { url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" }, + { url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/47c5fb0be8bdd00116814c2c40d9ec42dbeb943865ef95130fe58a898226/pandas-3.0.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:50c44cbf5820b6b91a5f74aae04972472aefadd3cd9fbd1010409d85528bd570", size = 10483960, upload-time = "2026-09-17T23:22:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/09/08/a310cb2fefe6d2b4623ab2150da818d93d4e73e33b58e4d163bb74243c5a/pandas-3.0.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:eb6900de08ac85f93ac4948aa6b80842eba555875337b8359035ac9c43e92d34", size = 10149432, upload-time = "2026-09-17T23:22:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/54/36/6af478ec3a26d7754555cd62c3101c589c0931b1d85398e4fa5403910a1c/pandas-3.0.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e25e2e1adee99ddfada6f7206a79ae8e9c8a8861b0e3eaaba165006d3eef18e", size = 10360981, upload-time = "2026-09-17T23:22:35.621Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/5ece0e9cb79a6473216620db6a90546f578001c2bd857b77c444b27acad1/pandas-3.0.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ff44b2cb51cbd691c91f92c4ea6c71e34003f239ebd67c2e857dc898466b49c", size = 10776585, upload-time = "2026-09-17T23:22:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b6/cd3038f31ade5e8d2b4e1c9549d4b31e4d598469562f47142b2ad9171c0a/pandas-3.0.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5edd0a7abb0986ecce1ac81f56d99b6763f86aa6946dceb6c661224f90af5a19", size = 11385249, upload-time = "2026-09-17T23:22:41.18Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/05bb3003737f80375d7311774916a24d161e2e591abe8c672aed7813defc/pandas-3.0.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1bcb3e9ed29e74a7439cedff9e2aefd3ea65de84d7de9ccb6c194192541bd60e", size = 11848595, upload-time = "2026-09-17T23:22:44.207Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/1c0d46acf236d19ef975e9cdd5d1e0dd54924b03f0ed8287096ad4a18152/pandas-3.0.6-cp315-cp315-win_amd64.whl", hash = "sha256:253e12cb9081b0afbac607920f6142975966bc315135e09de275fdbaa415d2de", size = 9783810, upload-time = "2026-09-17T23:22:47.097Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/df3304a9c2833c4810e7f1c887b04105b24731f9deef8b5d5d04522a375b/pandas-3.0.6-cp315-cp315-win_arm64.whl", hash = "sha256:97274c9adf6255bb48c620cd6959805efa7f09ea2167f0e0ae006a448cd2fca7", size = 9116820, upload-time = "2026-09-17T23:22:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/47/25/d5f6cce5efa17c38e4f752a875b4a26d66cfadd529cb8c81672f0376d6a6/pandas-3.0.6-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:265f562fdd1079f69f3de96dd425c3405224038c0af4f920c54bd240ee2c4640", size = 10894614, upload-time = "2026-09-17T23:22:53.223Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8b/e1876bfdc1df06bafc022d33202b5663bd86c81df2a6140aacafd0344669/pandas-3.0.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c6e4aae3e9bea26c6c9a20d88d96c86ec4a99b4db5fd516bcb4e829ab2c0ee36", size = 10546973, upload-time = "2026-09-17T23:22:56.155Z" }, + { url = "https://files.pythonhosted.org/packages/e9/27/e0a27a5a5c27f7db66657b44def9e93121fd0ad4f0808355b9898fcbf204/pandas-3.0.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a77a1a44e4d88f1c6a2a64d3eb12efec8420875722e14279800b173a7c7c2804", size = 10239717, upload-time = "2026-09-17T23:22:59.603Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/4eadb7d86a921c1e8bc70916cfe601ed667c4169118d17d69958611c21f8/pandas-3.0.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fa853a12e0b70927e2b1ee00d56d2224ec9cbb4b9d58348b5ad52d2f21150e", size = 10641275, upload-time = "2026-09-17T23:23:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d1/eba72e9d905e84e79aefeefdcc6bc1abe15d9077973566643f4211636966/pandas-3.0.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c826e9babb7790142c399f58599d8de679bea059d7b39c5b6efa2096fac37266", size = 11262919, upload-time = "2026-09-17T23:23:06.038Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/1c5bd2642b450e374b6191189f47c49538fe41f82348a66de6f647e6ab59/pandas-3.0.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8fe77b408d82e2615674dfed62533b95e18a03610573877422aada4f625d4947", size = 11709148, upload-time = "2026-09-17T23:23:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/73/3d/1b142bd0d0f1326a5d98c91c955b923cd1e06b5eecb428cd9d8817fa01c0/pandas-3.0.6-cp315-cp315t-win_amd64.whl", hash = "sha256:83e91d15738d7783c050197cef2f2cf82fc6353dae9865aa87ed1fa16aa4d55a", size = 10161518, upload-time = "2026-09-17T23:23:12.365Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/6419c14da2adc1f09a6a183b8f91d7494d325b287f4ca984ac04f663638a/pandas-3.0.6-cp315-cp315t-win_arm64.whl", hash = "sha256:963ca21199097a84c7827c4678b04e30833084fbf8ef44fde3fa7180a29f8fa0", size = 9364529, upload-time = "2026-09-17T23:23:15.274Z" }, +] + +[[package]] +name = "playwright" +version = "1.63.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/dd/fbb3d34228ad753bc14464d5f2252585b73367539775a88d0a3949cf5a45/playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d", size = 44278407, upload-time = "2026-09-15T16:49:06.045Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9a/948b930b1a8c4ee869e5a139a2b7747caa06aab56a3f09a2f0abdcbda221/playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24", size = 42944679, upload-time = "2026-09-15T16:49:10.039Z" }, + { url = "https://files.pythonhosted.org/packages/94/11/dc5c13fa1602371603acd461be47529c1b3513815d3a0dc98f642c291a10/playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f", size = 44278410, upload-time = "2026-09-15T16:49:13.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/9c/103a5037789062bdab27c7dca53f3ca6b075b572ab2cd96eec825b3aec4e/playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de", size = 48217159, upload-time = "2026-09-15T16:49:17.404Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/3d85505284c5a210f2da6c07b8f757524e79d1fba9cfdafe1eafb766ae59/playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7", size = 47902708, upload-time = "2026-09-15T16:49:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/69/8d/f74ff6b52751859f4b69caf66aa7d4a3c8d6c1f0c7dc9920da103612ee39/playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482", size = 38606075, upload-time = "2026-09-15T16:49:29.546Z" }, + { url = "https://files.pythonhosted.org/packages/76/eb/d6b8d92658038e260dbc7dd69fb3fdbe245aac283953de8b50b02bfe5f61/playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6", size = 38606082, upload-time = "2026-09-15T16:49:32.838Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/2432b3e3c7c62103b72d4c4cc8b16a56383ada372bbb0d1278591e988f71/playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b", size = 34523554, upload-time = "2026-09-15T16:49:36.455Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyarrow" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/34/17c34cb38e5d940e38f0f0d9fdfa0e8a506676409ea9b85aff7e3079f831/pyarrow-26.0.0.tar.gz", hash = "sha256:0cccd36e00ea3afeb52ded61f2721ce71f604853d70c45365c58324eb773d6ae", size = 1239433, upload-time = "2026-10-09T08:26:25.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/60/6793778f2617cce469383dac0ba08c4f2401cf342df0c7b9ca53939d9b46/pyarrow-26.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:90ddaf7c625307ad52f31a9b25c34fe5e4897c7529ee3481135822b2b6842ff1", size = 36333953, upload-time = "2026-10-09T08:14:00.387Z" }, + { url = "https://files.pythonhosted.org/packages/db/81/f944cc63ce8a753e5fbff25de6d1d475ebd7fffdf9cf98c65130294fc896/pyarrow-26.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:ee341973f78a0b46e073d065e88e75026a9c584051e97f98a0d05d96c6bac7dd", size = 38688456, upload-time = "2026-10-09T08:14:04.344Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7e5c722fa5d5d9f3b75e62fe11694b34217664d4f05ac88031197166b277/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:01c863a18bd9c8412453dd0d92de6d0ee7b2b3d6fb079d9734a4b2a3c8bd4453", size = 50867603, upload-time = "2026-10-09T08:14:09.115Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/9cd356d906e71bd79b0c3fc5c9a54e01a0020dcf14c152ccfbcb503c7298/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6a628922ba20705fa964ca73e4ef959c2fb2f14b9bbec5589a6a1e68e6257c85", size = 53931932, upload-time = "2026-10-09T08:14:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e4/5bae3133b7fe04c24907a20f3bc1fba388cbbde659199e7b76445982047a/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:954d971b363b16ee41f89389a4053315dc71265f2ce5c2468eb0a910b1166268", size = 54444720, upload-time = "2026-10-09T08:14:31.214Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/ee422493bb6dafdbef776cfe2c2a73106a1063a79bf4e78d1e5f51176885/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5d5768d03426abe6526d5274adefa00abf00a7f81118c46e98b5a46390f5549e", size = 57388949, upload-time = "2026-10-09T08:14:38.964Z" }, + { url = "https://files.pythonhosted.org/packages/54/3c/1783aab1dac28e175dcf26dfc7123725efc474caecaed91e8a34cb89cad0/pyarrow-26.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cc903e1069e9dd5e9dcf780324c0112e27e051e422ecfaff574fb33ed65d9160", size = 28567581, upload-time = "2026-10-09T08:14:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/4d/35/ca95493712af97c46a312945c8e9d16b21c5fe2f148be5466168d0290505/pyarrow-26.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a6ca849f90cf73fe361f08a5762c783ead9671e4548c1f558cc637b54c9103f2", size = 36336700, upload-time = "2026-10-09T08:14:51.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/ef/b1a675f79c9babfd4fcd99af62141d3c2d1a78a524e311b0c6b80110445a/pyarrow-26.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:c2ba350957076b1b3a22f549261dc3e9c67ca20816d8bd5f79d7b9c69be4c4c2", size = 38698502, upload-time = "2026-10-09T08:14:57.114Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7c/cea852a832a327a8de797b3a68e5c25ce0f5aa1d20503807671bd90ec642/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e3b190ba1d3d22a5a8758597f797111b77d433473744352a184a5ee0a42d672e", size = 50865064, upload-time = "2026-10-09T08:20:01.614Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/e95834b29360092376fe4da9956ba41bb7b021869efe6ee9d4172d05cb15/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:240bd18a7487f8767616a948a69dd4e740a8bc36a1c9da49e4dc9a32c5c2faed", size = 53926722, upload-time = "2026-10-09T08:23:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/98257444e2aea2e1fddceee3af3bd2077236d550428413f80393bd1f888d/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2b5fcd69c0e1107b79e55839877db5a6ed04651b73fd6fec581d09e230bed5e4", size = 54443093, upload-time = "2026-10-09T08:23:16.971Z" }, + { url = "https://files.pythonhosted.org/packages/88/ca/dac99cfb25cfa62bf7194600cc99abc14a6bd2af50d7fdb7f15eeaf6e202/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f7444ea6975c49a857c68f9bd8fa11acae96dede63d120ffb3bf0a603ea82516", size = 57381937, upload-time = "2026-10-09T08:23:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ed/138d29fddaf803b90f4527e124bb6aaddc18aaf4a6c50fd0a5f577c94989/pyarrow-26.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:3de30a7432b48b98b9decbd9e25a53bb9251d202c2e6c5a29a50869592ccb117", size = 28478571, upload-time = "2026-10-09T08:23:30.535Z" }, + { url = "https://files.pythonhosted.org/packages/8c/32/01858422a37f083911c2bb4d15cc32c5eeaa9d9b2bf5ddedee995a7146a6/pyarrow-26.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5780d487ff6c6ed7b42298609680d87fe0036e529a9dc2e1105364bce9697f50", size = 36378402, upload-time = "2026-10-09T08:23:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/00/85/f6b5976c2878b752d0804d371684e0495a71de296b6dc6559e6fbaa4311a/pyarrow-26.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:a0e4e92eeb088f1d7c2c04d6c7de8434c75abb4b4ccf0bbcd045aa7164c68d93", size = 38733074, upload-time = "2026-10-09T08:23:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/81/bc/c90fcbbcf893631e23dab1b0fb3fa29a508a8614326571b03c0894eda00b/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:eaf9e7cc7ab59f6c760232bbde18f64d559bbc50544841303bfb32be53533297", size = 50929201, upload-time = "2026-10-09T08:23:50.507Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c1/0c1ff38ab7df1b2cf54cf0ad9f19a516c4e416c6c9b4c966cc2c9d587f77/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ab6914db225d7f399652ae1f08588dfbc9efe617612715701e3d9d5cfa5ca19f", size = 53951865, upload-time = "2026-10-09T08:23:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/9f/70/6a6b170496925472adad45a32528770fc8632db35fc60d4edd1e9ce1be0b/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41dd3661ef40790a78870052ad7a58ad827b27c67a4511f06962eb9e9b74d19b", size = 54496388, upload-time = "2026-10-09T08:24:05.23Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/033ef9dba80976820190e292a10a5a23e9406572b76bbeb4d685d90e5c8d/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6e949744dcfc2d379808f7013c5f9cafaf0f817656dff7d46c6931528dd1784b", size = 57411588, upload-time = "2026-10-09T08:24:12.043Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ff/a74892c50aaf1f9f744a84493e08a2f99221e77c39d2d4a926de21a99edf/pyarrow-26.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a5fa8dc70dd50808990ff36faf44088e357b353d86c7682dd92d4b78d4c97d5", size = 29237858, upload-time = "2026-10-09T08:24:58.106Z" }, + { url = "https://files.pythonhosted.org/packages/03/10/f0ee0976ef08a851a743c57608917ac9a47623f688b9ee0efe5429975ba1/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:e2a1856e9565fe2679863b372478c681806aebbf7d0a6e72f33e77f804e647d6", size = 36495870, upload-time = "2026-10-09T08:24:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/0bc431a509bf10b4472dbb94f4184752ecbbddeb7f467152dac0fdaed469/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:4bcba83299cb2b8f8e443d36c6ba6269a5034431879015fb0719495df8a14de2", size = 38819754, upload-time = "2026-10-09T08:24:20.875Z" }, + { url = "https://files.pythonhosted.org/packages/61/59/2be41d26af7a07fb71581fb753cae396403ba1a2978355fd553929d44a9a/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3a4d235876f14b4136b4d616ec42eb469ea0d6ead336cae631aa1dd29b21c962", size = 50933671, upload-time = "2026-10-09T08:24:27.199Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/b6d5048cf3178be9678f5c9c60040199894b2f69c3439c87ced91fd24da9/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:210cc9b83888b87cdc8f793eebb264f22b20d0dedbedefc73b9687a7047b4747", size = 53906419, upload-time = "2026-10-09T08:24:33.536Z" }, + { url = "https://files.pythonhosted.org/packages/09/2b/23e30fbd776c81d18d134d2592eb60daca13e8a57ab087d0fa042f9d9f3d/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca77c43ca55bfc9a4eeb1f0cd5f093f08731b77c24cdba0829035f084959b0bb", size = 54527960, upload-time = "2026-10-09T08:24:41.292Z" }, + { url = "https://files.pythonhosted.org/packages/e2/23/fce251cd6b0546dfc181b00d5c8ef1c95a8c4cae83266bc3dfd5f719c62c/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:290a74c48e9491b436fd5edacfadf357943f82aa45c81110bd83a69aab33d1cf", size = 57388010, upload-time = "2026-10-09T08:24:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/44/a5/0126fb0ef8d59bf257bdd68bb41623b72afc6e81790a0b4ac863a0f58861/pyarrow-26.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:515a10dae2a1d236bc9c9209d0317acb6746ea63cd4f98704904af7156d90ed1", size = 29406123, upload-time = "2026-10-09T08:24:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/ed/66/8ada1b5165359d84b4b9b5384742304d1081da670f77d458fd9c9b8a2161/pyarrow-26.0.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e890816e5ee89c74a0f8b9379fe8b5ba83f46132b2a0bbb9b1c21359ec30dfda", size = 36373215, upload-time = "2026-10-09T08:25:03.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/83/74f10c3d803a6834b2acab21847724d4bdbc74d246eb17321432844707f3/pyarrow-26.0.0-cp315-cp315-macosx_12_0_x86_64.whl", hash = "sha256:9db18a9dc0af52135c9eac549d80a7a882696efbe5406cf882b044525d4ecc2e", size = 38730866, upload-time = "2026-10-09T08:25:07.924Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/ea2fa2163b1bd8ff73efd39c4060be63fd6ddec03e7887a471acd1e042a4/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:734312d3d99088d9ec28c5b17bad40389bd8373a1afc10acb60b83fd217af087", size = 50924443, upload-time = "2026-10-09T08:25:13.864Z" }, + { url = "https://files.pythonhosted.org/packages/78/80/8c47b6cf8cfd42826df65193eff026c1cc81fa6cb213a3c3f5d203e6f67a/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:24f892fdf1ae1942d69d3f7742e2f49960ec95277cfb1a70b8a1d91f4a96d935", size = 53948540, upload-time = "2026-10-09T08:25:19.305Z" }, + { url = "https://files.pythonhosted.org/packages/69/1f/3a506a76d944ec5c5e4b7f01d8d0446b392a6fb384de627a12e503f616b4/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:879331ddea2a26479fa18fade71e6facf684a6cf19f67daec3775c871569e8e5", size = 54494863, upload-time = "2026-10-09T08:25:24.517Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/08c4bb04d651788d2eaca78065743f4f6ded974d4ef96ae3c473993e9d0c/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:5b827650e874f1f9f9392524ea3e9e3e8a245de5ba64acca1f81ab188090afb9", size = 57409877, upload-time = "2026-10-09T08:25:31.157Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f3/c64781fbd7b6d3c07993b698c14944d0d195f07e800fa931c486ae6ab36a/pyarrow-26.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:8e8e28c464552b5ca03e30d4504168c4425ce383884f8611b00e972f9fd933fc", size = 29236658, upload-time = "2026-10-09T08:26:22.607Z" }, + { url = "https://files.pythonhosted.org/packages/06/55/2ee3729daea999f19f061f03898d4895a242c4cd94f26e1324e5fdfbfe10/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:ce28748cbeb0f29c3ce9603782979c7117580fc76f16aa3ca448b38a22281adb", size = 36489011, upload-time = "2026-10-09T08:25:37.64Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7d/3eb17f601f2bf13eda5f2ed28956379ca628b4dda97619cbb1cb1721622d/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_x86_64.whl", hash = "sha256:106bb9290fc6fd9a84138a9440038ef184bac86463543c5ff099229cb30d996c", size = 38808480, upload-time = "2026-10-09T08:25:43.579Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/f0047360b0f4bfc031b256dc0aec3837a61f245b2fb70f8363438e2db665/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2e4a413046eba9896e632925066c74095182200ba32e19ff0166bf64d2f936ac", size = 50923273, upload-time = "2026-10-09T08:25:51.445Z" }, + { url = "https://files.pythonhosted.org/packages/38/d9/56d9fb91210407df31cbeb9b91138601c88c7c8fb5f6bf773b20d65509bf/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:d58798c4d8d629700058e9afc1e16b9801023f3ce4dc1c92d945e79b5ffe4e98", size = 53900905, upload-time = "2026-10-09T08:25:59.554Z" }, + { url = "https://files.pythonhosted.org/packages/cf/40/8e8a7e9e027c731520c7eb179dd00a153b76ebf0bc11d213c6c8f8502851/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:645917e976671debabf854abab6e2b75c571ca4f82adc33a2d338697f7c27d93", size = 54518345, upload-time = "2026-10-09T08:26:07.125Z" }, + { url = "https://files.pythonhosted.org/packages/be/89/1e768a3fdb88d34e708ad2dc00dbf8e4e30290784eb84198d59308963bea/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7c3fda041e7078802589cf257750323ee3d0cd1e56e53a9b20ec845697fb3d28", size = 57379403, upload-time = "2026-10-09T08:26:13.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/be/7b81a44d6a8e70581dcc1d6f01541f9000a973b1e5d75394aec91e7b179a/pyarrow-26.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:68cd662e9e2b00876a131950cf32336ace2d0865e1f9418763e3d3be8481dfa4", size = 29389953, upload-time = "2026-10-09T08:26:18.277Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" }, + { url = "https://files.pythonhosted.org/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" }, + { url = "https://files.pythonhosted.org/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" }, + { url = "https://files.pythonhosted.org/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" }, + { url = "https://files.pythonhosted.org/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" }, + { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" }, + { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" }, + { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" }, + { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" }, + { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" }, + { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" }, + { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" }, + { url = "https://files.pythonhosted.org/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pynndescent" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "llvmlite" }, + { name = "numba" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/fb/7f58c397fb31666756457ee2ac4c0289ef2daad57f4ae4be8dec12f80b03/pynndescent-0.6.0.tar.gz", hash = "sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5", size = 2992987, upload-time = "2026-01-08T21:29:58.943Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "pytz" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "regex" +version = "2026.9.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/48/3fdcde9a0baa84d7d25571223265d6e434e114763b438601d54a8028bf3e/regex-2026.9.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc79d36d0618752265f0d575915bdc5c5130ecb9c9f6b3bcefeae32e4bdfafcf", size = 497903, upload-time = "2026-09-29T00:46:38.938Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1c/4ee3e97c76f53940488dfe7a7e18705e78daac8cd7fb161d246b9e328449/regex-2026.9.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3a21a9509d0ee88e7a70e1ad228cd2f0e0fd1e187458db132e8a8d18c97daf9d", size = 296416, upload-time = "2026-09-29T00:46:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/37/14/f3f0ba083d2094392d5eabf56db5ea6ba469fd6e927afd187042054ea68a/regex-2026.9.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f57dc6b8fef170f105d2cf5cdce254f47b137d7755086cf7050f47e16582abba", size = 293633, upload-time = "2026-09-29T00:46:41.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/72/67e7a8ce17f1aea49df215564048efb49cc8c2b31a0e0fc30f36838f8516/regex-2026.9.29-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc1c3486ef3747e07c9d7c1d0a147b8fbaab975f80e348aed6f71309dfaca", size = 805885, upload-time = "2026-09-29T00:46:43.373Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/25436bcfd4d2260b4b4090094d55d7ab53ec8a1ab4865a0b8bcb33c7d5c0/regex-2026.9.29-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d3a4cb7993b708f0ada8d0c84590efd853f169e7147d2202c9da503180242", size = 878344, upload-time = "2026-09-29T00:46:45.328Z" }, + { url = "https://files.pythonhosted.org/packages/97/e6/a09ec3a23ae41d6179880e67f0aace9284b2d95f2d7b326eff203f8eec5e/regex-2026.9.29-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dabee8f4935e731fb46b2a3091bdda0d3d94b3bbfb907d2b4f12eefce4009619", size = 919181, upload-time = "2026-09-29T00:46:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/26/83/d2fbd2e4e3afb1167daa825187d196f313cbaa1a4768f311fb041bb0e3d2/regex-2026.9.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ab5894d971f9ac68baa6eca5c50387db579cfcacf36ae8df3feceb1815e6d0", size = 807783, upload-time = "2026-09-29T00:46:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/46/0b/eb429a7016610d44fc89a597163f8c9127505f0d7dc724dc9effbb6a3ac0/regex-2026.9.29-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1a9a6651197fbed6f0212591418b9def774fc3f8324f78d1bf0e6a63e5f8aa1", size = 783465, upload-time = "2026-09-29T00:46:50.64Z" }, + { url = "https://files.pythonhosted.org/packages/1b/07/58a3c0153c7476898430f6a7cf3d9062a1d17fbea4f43399ecaf411c7b4c/regex-2026.9.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87fb80cbe3557e27e7b28b995c2b2eedf689b8886f941ab93e0e288f0976518a", size = 793519, upload-time = "2026-09-29T00:46:52.396Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/161b94d39164520e21a7befe0245569bf7fda4c7cf1fc4e2df2b5def49da/regex-2026.9.29-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c5c2ef13797466aa64170cbb66ad98a32351dd4127694cea7199f80f213750d", size = 869293, upload-time = "2026-09-29T00:46:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/8f/07/3b02ed829aa2decdc1955d222bd1e2f99d1c8bb4873bbb9a66b2f0a36bff/regex-2026.9.29-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:59b49507f47479e299a9e1bc41b5cb83a7afda0540625f1dbae886615978acbf", size = 770239, upload-time = "2026-09-29T00:46:56.106Z" }, + { url = "https://files.pythonhosted.org/packages/42/5b/ba61f6fe062eb8562e742367d177bb75370434138ef6c9d2a27114f8d613/regex-2026.9.29-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0dd8af32e9f7b56b7f95cc1fd79b23054c3bdc172392ae560acc24d57b7ffe71", size = 861973, upload-time = "2026-09-29T00:46:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/767259b20e8a842948990f5e99138d6c077248fd42f8b5468b1d9ca4b814/regex-2026.9.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db5e82ba15c142425b8406690032df89e39cca4a2e8afbbb9a3d84edc2373ac3", size = 796470, upload-time = "2026-09-29T00:46:59.236Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/2566c4ba849b68a8ab81a6bf428fa79d20aae7ddee83979103c0381df254/regex-2026.9.29-cp312-cp312-win32.whl", hash = "sha256:d0c3082bf79bcd6a614d55916590ad4b8f93200e10b97f463ea5d9d07c9b5f23", size = 269327, upload-time = "2026-09-29T00:47:01.135Z" }, + { url = "https://files.pythonhosted.org/packages/93/19/489bc8db91196381c935752df01ba3f607140daece33b78d88573f028e64/regex-2026.9.29-cp312-cp312-win_amd64.whl", hash = "sha256:fdd88ed5e20b1bcdd234421e454962c971aa44b653bdb7f1ea9ef683e90fb649", size = 280334, upload-time = "2026-09-29T00:47:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/fb88ba779d0e5e7d4b0ec1aceeb13845948a2cb876bd572a2d1dfdba090b/regex-2026.9.29-cp312-cp312-win_arm64.whl", hash = "sha256:4fe97894d1b306c919b4e50def1e6f6c522f4d03a7283811f4d108f1ce5d3ac2", size = 279614, upload-time = "2026-09-29T00:47:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/79/d5/6080f7d1a6e7e36aa720f806ac93c035ba39c209ae6cc510e8ef4c0279c6/regex-2026.9.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1a0d5117230dd46b399a30a38afa44f79c99f3168988fdc4f425c3f928b39df", size = 497622, upload-time = "2026-09-29T00:47:08.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/c87fc7a2e21a42f9d57489db32951c37eef56d153840459a80d464f0321d/regex-2026.9.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0fe9834e5aeccaf19a0d8feb296d66a24be1a7c9922002f842a682cd5abb787", size = 296281, upload-time = "2026-09-29T00:47:09.764Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/aa0f4cde3bc4688c1d58b0cd8415edd708339bc0bc401a195b0b1e8c8f0c/regex-2026.9.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c90fcf7804ea0a54b896ce0f2b9565350220b8d4890fd0db461a476a4c687963", size = 293472, upload-time = "2026-09-29T00:47:11.723Z" }, + { url = "https://files.pythonhosted.org/packages/90/d4/e835c487850ed922a8d6074f953b888c8ea99775c76b9ed5f8a4d72eab92/regex-2026.9.29-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e11edba5bc344a32b029a7af9d4b3173982dd79eeafa0b9dbd787364414b0509", size = 805966, upload-time = "2026-09-29T00:47:13.235Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/ba8809847fbae8d2cbc71367c6ded510a7ec88bf52493c65efc1acf4effb/regex-2026.9.29-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb90e7177944b6684738c1fc36aabd2dd00d1de3be7dbe09f91e196f1bc0dc81", size = 878346, upload-time = "2026-09-29T00:47:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/e3da19fc3cc15ef67ab67e121e87887c3bccfdb683a7a9ec557c460ca5b7/regex-2026.9.29-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d06fcdecc10fc7954d7c8f27a03c96055fe525274dc84a7b0dbdc3d6b9e03dab", size = 919250, upload-time = "2026-09-29T00:47:16.622Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8e/c1ed81f55f992f6aa0b699a592a50c1ce9e6d44ff1aee2c14c0537dcef9c/regex-2026.9.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d49c18f1ea294cf4adde2e5ac256e98c82ea9d708462ce4bf799dffa7cfe8a2c", size = 807902, upload-time = "2026-09-29T00:47:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bc/5a6886eb470e41040e21e05b75024a18b6ebfe7ea400b72094a60f949101/regex-2026.9.29-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e778bfccd63075167709136afbc251c1f683758d5bf49c803c60ac3f894ce6b", size = 783514, upload-time = "2026-09-29T00:47:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/6d951d453b023c6edb880f1ba474291b53b8ce1cc438b96a9db6d791d991/regex-2026.9.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:686ac5350fceae63830bb98805fcb8039325bf4c06d9f6f048ff65229d5bffa5", size = 793466, upload-time = "2026-09-29T00:47:21.552Z" }, + { url = "https://files.pythonhosted.org/packages/99/b9/d5a41adc08360f5eee0dc4846c578f002366947211fc8af5a69a64ee7b9f/regex-2026.9.29-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:26ec4ccce55aa533fbd603d08911b01101a8fcfec987845ac3ae2c7087b2bde3", size = 869464, upload-time = "2026-09-29T00:47:23.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/d76c9d91f5d798e2e9e67f6f85ec4ae35445ac425f7454797311cecb80ca/regex-2026.9.29-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a655d34b2a6943af32401f3d94f72e9d731f6ad16285815550bf2b4ee69d420a", size = 770278, upload-time = "2026-09-29T00:47:25.193Z" }, + { url = "https://files.pythonhosted.org/packages/24/00/aeebdb540c620a0f7317f6d6fad80a47729ecf0599a24b5c34ec155351f5/regex-2026.9.29-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0c992c19cd45058a4b92f68f139c93db168b48fb1f322c9a7cd620806afb6b51", size = 861949, upload-time = "2026-09-29T00:47:27.005Z" }, + { url = "https://files.pythonhosted.org/packages/12/62/d0314bcedfd3586197e4596931fa220260eb2385bf53184e5b9ae67db24b/regex-2026.9.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ebb8912f565b8cdbbf27debfe00df04202c20e2f651b9e32767930c5eace3621", size = 796602, upload-time = "2026-09-29T00:47:29.233Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c7/d5a8c13a613facb03e0fb55c1ebaaf7bb35d8e2c1abe8bef8dca809fc1d9/regex-2026.9.29-cp313-cp313-win32.whl", hash = "sha256:4d7d93613b01b0199961330e49cfc52d479b3d5776c56c691db31130c0a07d91", size = 269306, upload-time = "2026-09-29T00:47:31.14Z" }, + { url = "https://files.pythonhosted.org/packages/80/a7/bf93a3a6afa5f7bc16b7afb94ae581b01cae620b8ad56bd8f9572a985959/regex-2026.9.29-cp313-cp313-win_amd64.whl", hash = "sha256:61956f074ecd123f55adca68ee3eab46e6a07ad3f8e64e6db95dfacb444f55c4", size = 280307, upload-time = "2026-09-29T00:47:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7d/388274e53605a86297f433a08102a7bbdcf9379d47683d307ccaefd88e2c/regex-2026.9.29-cp313-cp313-win_arm64.whl", hash = "sha256:bfc71e6d970419c1309b3640305298643e2a734cad3f7cfb6d2ddee4175ab53d", size = 279599, upload-time = "2026-09-29T00:47:34.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" }, + { url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" }, + { url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" }, + { url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dc/d84990386c9dfdf8c377f00f371b241fdc9a2c8aea0e3d66941b2e51be0b/regex-2026.9.29-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1", size = 497836, upload-time = "2026-09-29T00:48:40.858Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ab/a569ebde875fa12ff8c6c9a30e07503620f195e4be4d54c3d3ee8eecc283/regex-2026.9.29-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf", size = 296244, upload-time = "2026-09-29T00:48:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3e/7d548e82a108e7c8b2d5246650e397a2f8db599f9b2e975466939c5b4e70/regex-2026.9.29-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563", size = 293748, upload-time = "2026-09-29T00:48:44.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/34/a8e19a52f452bbb07b32a2bef70dcdf90c2737049749f74cc12d7486fb4f/regex-2026.9.29-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e", size = 807840, upload-time = "2026-09-29T00:48:46.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/7b/11fbd4640b3bb82b72822a63c20ade4013d562d291703a9debeedc24e682/regex-2026.9.29-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed", size = 879330, upload-time = "2026-09-29T00:48:49.168Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de58c74f1f4e31586d83eb39c56872d686c4e0d0966d151884c833b94ced/regex-2026.9.29-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f", size = 919251, upload-time = "2026-09-29T00:48:51.322Z" }, + { url = "https://files.pythonhosted.org/packages/81/42/a8c480f6dd5ac59fa28ddae79afd9d7ac7e596fdb61813adc65bb6e674b8/regex-2026.9.29-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d", size = 808808, upload-time = "2026-09-29T00:48:53.529Z" }, + { url = "https://files.pythonhosted.org/packages/68/60/0bc0d1ec8b37ad64be6fa30e035251f11de9667a0fac9e82ee74517d81be/regex-2026.9.29-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650", size = 789907, upload-time = "2026-09-29T00:48:56.036Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/116a3ef19b3acfe81077f0bf2cbc7714a5e94bc8935b7243ab61cb0f1c3c/regex-2026.9.29-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5", size = 795770, upload-time = "2026-09-29T00:48:58.284Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/e38c3f203e7e7e18c957d48e6cb6dbf96c11e95a44efa4a480522afc5d6d/regex-2026.9.29-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699", size = 870671, upload-time = "2026-09-29T00:49:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0f/9ee0b0cb76c55f63684bd7fff554978e8773b4fc86e2bcb2d50772dc1086/regex-2026.9.29-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a", size = 778631, upload-time = "2026-09-29T00:49:02.984Z" }, + { url = "https://files.pythonhosted.org/packages/b6/19/e6e3eeb226af5872c4958002f6edef4e4f40ea4cc5f5665023f2019eb045/regex-2026.9.29-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b", size = 862187, upload-time = "2026-09-29T00:49:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/5b/62/823c102e106bb2711d6b7dfe5981552fe4467b2969c46a20c5c383cf498c/regex-2026.9.29-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d", size = 798423, upload-time = "2026-09-29T00:49:07.644Z" }, + { url = "https://files.pythonhosted.org/packages/37/e0/e927776258fa70b2f6feffc3be584ffc85ba4c1e20a320f0aee9a632fc7d/regex-2026.9.29-cp315-cp315-win32.whl", hash = "sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47", size = 274757, upload-time = "2026-09-29T00:49:10.513Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/358de85d1860238e1b4fa98fc2c80c990124a25d2e14739e28cc02c25562/regex-2026.9.29-cp315-cp315-win_amd64.whl", hash = "sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b", size = 283824, upload-time = "2026-09-29T00:49:12.849Z" }, + { url = "https://files.pythonhosted.org/packages/92/d3/d5c5b264784a5ab2b0f8cf620c1eeb4dbf3440d306761905e7d99345bef5/regex-2026.9.29-cp315-cp315-win_arm64.whl", hash = "sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895", size = 283664, upload-time = "2026-09-29T00:49:15.331Z" }, + { url = "https://files.pythonhosted.org/packages/02/dc/f63ec2c201445ce1150fe780f5c56f16a10124d9a9da3a93161dbb0d8892/regex-2026.9.29-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c", size = 501549, upload-time = "2026-09-29T00:49:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/d2a698dc6bfc11fbce03f1cb0249c13284e93b79ed11f893edf6fac431c9/regex-2026.9.29-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb", size = 298187, upload-time = "2026-09-29T00:49:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/85/b7/88dcdb38cd3935d4ee9e9ce9b8e56cb3b3518d1f020acfa7dd62ad289bf8/regex-2026.9.29-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f", size = 296157, upload-time = "2026-09-29T00:49:22.342Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/ba6c01dde33a69fc294b38b43f6677baaa5735a6248f39708031a738158a/regex-2026.9.29-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff", size = 818468, upload-time = "2026-09-29T00:49:24.612Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f1/2586693e3a2d6b1247852593d37a6c17b42a92ee44f7cdcb9a0c1494e64a/regex-2026.9.29-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da", size = 882825, upload-time = "2026-09-29T00:49:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/30/51/084f3e7bdcd0e9c33665c938cf5d134dc3548cbb4a75f0197ec7bfd754b1/regex-2026.9.29-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b", size = 923314, upload-time = "2026-09-29T00:49:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f1/066c6fc23b7dc229789c21c880b5ba5ad689fb95fed12e078266f55a1f9b/regex-2026.9.29-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223", size = 822222, upload-time = "2026-09-29T00:49:32.404Z" }, + { url = "https://files.pythonhosted.org/packages/0a/56/592cd46fdb8f2f8682a1d7fd1310e4d0bcb93fbd0e6bbe4141ac28240227/regex-2026.9.29-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d", size = 794882, upload-time = "2026-09-29T00:49:35.076Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4d/d65384bb071c864b01aa8314e3a6a687845ebd57588390976edc960c218b/regex-2026.9.29-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f", size = 805887, upload-time = "2026-09-29T00:49:37.395Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/358de0d8f40d5178e4f7e7e121cfd5b961c812b77a055d11f5079e3f8fd7/regex-2026.9.29-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa", size = 872901, upload-time = "2026-09-29T00:49:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/06/6bfded72d043240c6b52bbb5e16f639d81affbf7484b4fe2ec45f3d4afc9/regex-2026.9.29-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b", size = 782970, upload-time = "2026-09-29T00:49:42.581Z" }, + { url = "https://files.pythonhosted.org/packages/5a/20/9f418a50baa78b3ed8308fcb0cc49e472dd000b7ef935a7295af202ea744/regex-2026.9.29-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138", size = 865441, upload-time = "2026-09-29T00:49:45.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/29/817c7eacdeaf8463123e949bd394c39ad024eea1ec38ddf5ad141da2f3bd/regex-2026.9.29-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db", size = 809431, upload-time = "2026-09-29T00:49:47.878Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/83aab3b5b739947f744135a7a3a446e25433ebc92b05e01aae197ccbfdda/regex-2026.9.29-cp315-cp315t-win32.whl", hash = "sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8", size = 276964, upload-time = "2026-09-29T00:49:50.524Z" }, + { url = "https://files.pythonhosted.org/packages/72/f2/6314b5fc68789b5dcc38885bc6e3d6986b34fb3372b7231088ee5cecaa05/regex-2026.9.29-cp315-cp315t-win_amd64.whl", hash = "sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e", size = 286487, upload-time = "2026-09-29T00:49:53.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/eb/eaf5e07fcc0da7149b0e084f24e54edd7441b9a89ce7e034032ae97fe3a0/scikit_learn-1.9.1.tar.gz", hash = "sha256:629cada3e33e2b9bf376cdc7614a47a4140b8aedc1d836579e359736fbd82977", size = 7786908, upload-time = "2026-09-10T18:34:04.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/a7/25f0a43d2fde306e8ef45f45121192f687b79beaf4bae8c21607c46c5e63/scikit_learn-1.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c0f8b5d09b44101cea2767f300680bada1ea27f976fe4b48b83950a4f55a49a", size = 8775209, upload-time = "2026-09-10T18:32:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/60/ea/57e57539ce175d774fc291ed091b0a6d756854b92cd92554c6bb4d0ae498/scikit_learn-1.9.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:8c14ce41d561f7749f990b41d6703fe02c4669fbc485e598e069e0a1967b488e", size = 8295541, upload-time = "2026-09-10T18:32:45.069Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/5721a174406bfba49bce20ae997b3b64cf355c3f623a2638284ab6a82156/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4c20a6c017d820faa7ac8c783e3d0c6a9a2e297bf9f55332ca17cdf7fd4d04d", size = 8871592, upload-time = "2026-09-10T18:32:46.999Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/a50162f3d29feb979ab6347c6debda506dfb525bcff3c50dd17606651c7e/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5d7b18a5b9dca241a74695f3275fa4c895a9dadc72b3d8df5fa9d1083c9b83e", size = 9166043, upload-time = "2026-09-10T18:32:49.343Z" }, + { url = "https://files.pythonhosted.org/packages/72/8d/27c054166bac671770d1ea0ef7716134fe8119c0df224a58c89fd735a61c/scikit_learn-1.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4b59abb30618121cc46b45972d6bf53a7128b4df4cd346c6ca6f4d5f9031e49c", size = 8262238, upload-time = "2026-09-10T18:32:51.679Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/493086006ea0c68ad62c40a8dece1961b61bf503f45400f133d47f56e5be/scikit_learn-1.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:d5945a2908be62350e2978344e62b56c1552c2ca4f844ebf6277c94944d647dd", size = 7902102, upload-time = "2026-09-10T18:32:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8d/b60d5e7354ff0ff5cc9400e60273696589d87a30b8b2235886a76d80d062/scikit_learn-1.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c2b312fd8c02951a364fa120ea08c1cec10d863466bf1701b013152d7537835", size = 8739300, upload-time = "2026-09-10T18:32:55.483Z" }, + { url = "https://files.pythonhosted.org/packages/2f/81/3c6392c03665d2899457a76e535a9a6f597dddddf3220fd2e1d790da88c5/scikit_learn-1.9.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:61cd968ab831a76d0ecbaf0347ab2270268716da28f94fd022497e3d6f205f13", size = 8262364, upload-time = "2026-09-10T18:32:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/0f/35/a15b8653499692879821301d48059376d6e68e8b65cd0f22d19b6ee83cd9/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5990f9c69e431bfaddcde1a6d7c5355243e026bc9b9e560c13893b90dab53fb4", size = 8823698, upload-time = "2026-09-10T18:33:00.632Z" }, + { url = "https://files.pythonhosted.org/packages/23/e5/688703d357e5393f708d98eb189fd415ae69e39f6de03c6bd4005aef6118/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e79d6e9b0923f1a978179822bd43d7f5543f45e970a00fe861f43486380aba", size = 9121732, upload-time = "2026-09-10T18:33:02.825Z" }, + { url = "https://files.pythonhosted.org/packages/96/45/a10add34c08184d373be9384660c75758128ca881ed27b503b6f6a742478/scikit_learn-1.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:2070f271e5375dc42c6bb93b461ab1c0aa5841d4009267e0cfd95a39dca94a43", size = 8237244, upload-time = "2026-09-10T18:33:05.26Z" }, + { url = "https://files.pythonhosted.org/packages/9e/08/7a89bcdadd1fff0d464d01056417b646c9abcbc54f7297a0a1203bba5ebb/scikit_learn-1.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:613f0a783ca05aa844a4e1ac42d48425058f2c52be73f40f8cd98b7cd111acd6", size = 7875398, upload-time = "2026-09-10T18:33:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/64/e3/b58e45082dcf3dcf0eb1192ee03545ec43d8c98441dfe20e88afca8442ce/scikit_learn-1.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5d117952769b563067656784e03c75a2d8235a7a05cf7fffa78a311e75aac08", size = 8747414, upload-time = "2026-09-10T18:33:10.698Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ed/d68115577c8b42b0442ebd8180945d4008880a33176094640e00b585128e/scikit_learn-1.9.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:8893bc6331f60f18d4ac75e12ed356e2dcf6a564bf767918b5b7ca54c8c8be49", size = 8279175, upload-time = "2026-09-10T18:33:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6c/06c7eb61a438e389cbf3f7210897069bec5a883dbe03c189ae792781e11a/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5492cf2df5226691c32611de8734bcf42148c6547ae53c7f4e6b847793addc0", size = 8881706, upload-time = "2026-09-10T18:33:14.741Z" }, + { url = "https://files.pythonhosted.org/packages/86/4e/0bab75490ca4b85fad8388739c7ebc71d9db553f8c69e39943ee8db0aaae/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993d332ff80e62efae9e39603b7e872297c418d780f01a01855269a3489c950f", size = 9152030, upload-time = "2026-09-10T18:33:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/9a/13/31c6f8ba1b7eecef9dd9558576c752d2ec5785fd0e456bb9fc59305be23f/scikit_learn-1.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:ca9051447455dae341d4d591eece7deb2d8e3d1020298fc87a81fc51e4da8f53", size = 8377024, upload-time = "2026-09-10T18:33:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/6d3cb8a45f5228f915acd66b819dd6b8232ccbe24532f51d278e3991df31/scikit_learn-1.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:90de6573f733a9fb79476ff1371af52a397d41c8b35f9146e20923db010d67b6", size = 8014196, upload-time = "2026-09-10T18:33:22.126Z" }, + { url = "https://files.pythonhosted.org/packages/66/6e/6befb2d5961490d18d9dbc16a5df37aa121d08bc9893316a6a363977a903/scikit_learn-1.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b5cad1624de8b75e5b9ccb7b0ce1ff1d01306340a3efc56d5529c5ba92392eb", size = 9066066, upload-time = "2026-09-10T18:33:24.396Z" }, + { url = "https://files.pythonhosted.org/packages/cb/18/11271f2f7db337db01f598e358721b1e83989407131272e5dd64214c28a8/scikit_learn-1.9.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:d137ce8a6142029fb5c35bd82f470c40cd9e760e5e2f7694b362c497c4ab3fa2", size = 8647654, upload-time = "2026-09-10T18:33:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/e7/04/9c15d201e1b6a2e81b8215865df7646c5a360f560831769c8dc92ac1ab9a/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66f852f7325b5070bc28329005aca76055a2def78faac039548ae889aeaa45a6", size = 8889070, upload-time = "2026-09-10T18:33:28.95Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5a/4cb6c85160af4a639e87a3b7bf8b1c25cfc3b504c5af710ca416a6dcfc5f/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748bcb0a4cc04aec470652c9e5ec68450948e867387e7dfade647107ade68d25", size = 9131227, upload-time = "2026-09-10T18:33:31.008Z" }, + { url = "https://files.pythonhosted.org/packages/00/0e/361440972ae3d19b90ea88a84791138a51de0e8432a770ba741b2c8d9ced/scikit_learn-1.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd925e893e5539be704d5edc64dbe081aacdab6b89d8c2977c1f6a7a453ce5", size = 8683001, upload-time = "2026-09-10T18:33:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/e2/8f/a9f405c5c0e2df6f343a871b40c97fb32969e3ccc38e3033dd118f3c261e/scikit_learn-1.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b01e5b01735d38474127ca3f49319b592506225a87793b27559816b5c75cea39", size = 8257996, upload-time = "2026-09-10T18:33:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c5/74a83ea39cef7cd07f53e06cc1cf51e79f35df74f81db956835d59ec34b1/scikit_learn-1.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dec64f31a6e0ec826aca6c1b39a51e16d946e400d4c0904316f3ca72ccfb825", size = 8747991, upload-time = "2026-09-10T18:33:37.4Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/cc93e8a7fe204e43d70e96e5eb89871643be20025eea05eb4fdaf19afe39/scikit_learn-1.9.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e1b468241f4a7a9a7a0d6479ad3cc47681cc151a4046c530f2777c3d68f08942", size = 8279340, upload-time = "2026-09-10T18:33:39.705Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/0c6080f0d356fb966053009e7f25e9bff6cf74b0b16195ccf0c3757d1ae8/scikit_learn-1.9.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ca869d0080a5723cde2d5a8b54a2da1ff7e68735a9e9adb3da1243183a0fa01", size = 8881917, upload-time = "2026-09-10T18:33:41.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/bb/98a31f10fffbd39edcc2f8bf4119b29652248bb110b7d45c84e68aa293ab/scikit_learn-1.9.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6754b7cabfc3df0b1f7b38f7a344f559bbae9d82f0ac5e3d48cccbd19fdcefdf", size = 9152805, upload-time = "2026-09-10T18:33:44.23Z" }, + { url = "https://files.pythonhosted.org/packages/f6/48/858ceff52213cfd97c0a069362071756bcb70b9fac9771b38d87c4cf7f17/scikit_learn-1.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:52cfdb1fed3a34362dbc0bd96f2e761a66fd5724d6901629f5a558f1f3bd9849", size = 8376719, upload-time = "2026-09-10T18:33:46.492Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1e/5337a871bdea53effbd154b61429df048f2665653251de74a3bd8a6dea9e/scikit_learn-1.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:ae6571a4828c6f5019bcd2b4125e5b18c0af3dbc9c99726c891f45f41335ec8e", size = 8014341, upload-time = "2026-09-10T18:33:48.762Z" }, + { url = "https://files.pythonhosted.org/packages/0e/35/150383a42d83ec4c7b39f9c50bd68408ecf04c19fc30ea5198fa42e67d9c/scikit_learn-1.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:48fefd8eb42bd4eec3e2d348149368ccd6d71987e20c30706a56a24eb86a6e73", size = 9059953, upload-time = "2026-09-10T18:33:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/9f/dd/aa0d738808540f7eaacfab93e01982db8ef1c1c7473ef0ad38193e6aebd1/scikit_learn-1.9.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:09f4d73049cd63575157f6b1060e06a8c83a4bd3488dbfaeedf35ccba7aad712", size = 8646602, upload-time = "2026-09-10T18:33:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/7e/cc/687ae4214c2f598906c3b9fa5f86fbaf834b35e20360625528ff1b713f06/scikit_learn-1.9.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3da53831534214322d9cb240fa6f390b36cf69eba727a6d4bd3238677630d70", size = 8880366, upload-time = "2026-09-10T18:33:55.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/03/82215cb78ad1c513a4498777571fb28444621ad26ef636287551767b7732/scikit_learn-1.9.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caae15634feceafa2612566b109a3082d3293167fac388eedaf77bff66b51983", size = 9129640, upload-time = "2026-09-10T18:33:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/4b7af4efd7909a4a0524a9f18457e4eb2eb60616eff2c7627eda8e3cdceb/scikit_learn-1.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ffbcbbbb44202fbe9bc64bced25a145759adb9ef010b3d37a8064958ac13df2a", size = 8678083, upload-time = "2026-09-10T18:34:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/31/27/068e484d4b83004302e0d9cfc1faca69bcc010d76fbb66a642446095af1b/scikit_learn-1.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:800dd22dd87fe97dcea484c24e85dd93cf1734d86bd74e668ad18f7967f4d1b5", size = 8259434, upload-time = "2026-09-10T18:34:02.678Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/dc/6c58154c1c65f758ea979e7139cb76993a9cfc662d14e9be3c4a667cfb77/threadpoolctl-3.7.0.tar.gz", hash = "sha256:61348cfb77d53b9242e0017029244b559b810c142ced65b4e21eeca1843959a7", size = 31961, upload-time = "2026-09-15T15:46:20.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl", hash = "sha256:cd8b60b5641b45c67bbf73c64c843235fc2d8a480c87389f52f5dbee893b86be", size = 26362, upload-time = "2026-09-15T15:46:19.168Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/62/167a842aa0429d45f5e797354fd4343a96f6043d67d0513c675c7b8d36e6/tiktoken-0.14.0.tar.gz", hash = "sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874", size = 38898, upload-time = "2026-08-17T19:49:49.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/da/e273746b9d24a63c776bc60fba914351573ad9c575b52601eb5e60632564/tiktoken-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e947aefe98ef74cce94923f90e48c98fe34eb1ec0a6bfdfadfc5a96359bfc36", size = 1094408, upload-time = "2026-08-17T19:48:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/fe6b1aca23331aa5271df5a4bd07bf68a7059254d47faee1b8272592a777/tiktoken-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cebe67765569df3dafac8474e4eccf5c19d24140492567a5e58a11445732a4", size = 1038499, upload-time = "2026-08-17T19:48:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/0b/35/e9f47647c9e163bd1de30fe1a491669b7248cfc67b7404c35c009a701e1a/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7db45b98e94adf4173a5cd7422b150999a7ee11ff847783a14f6e1b80cc38cb6", size = 1186355, upload-time = "2026-08-17T19:48:51.93Z" }, + { url = "https://files.pythonhosted.org/packages/51/11/9976ad86980a00cdef05e730a0127a2578a1bc6d11644d8d47246de2eb26/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7896eea257fe497a2b7134474d909156c6744ce8da35bce88011a960e008aa0d", size = 1204197, upload-time = "2026-08-17T19:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9c/7035b0bcfaa68d1ee4803fc5be5214ad865669b05bd20e7105ae8a18afc6/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b950248272f1b303dc32986396e2dccfa10cf6d1e83ec8f0bba1776660305482", size = 1250635, upload-time = "2026-08-17T19:48:54.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/69cabf18bed7f4366da076735816abce0d4db3fae491ae338a6612128777/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3de75343041a1c57333b1e707ac8a9769738241d7d6a55d39e12cf84548337c6", size = 1316085, upload-time = "2026-08-17T19:48:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/bd/bd/a2e884fb1402cba5be08836590320012b2d8ada0e2eef9911a64df4bcd2d/tiktoken-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:087538c080e5ff421abd3a0785ed63c5111d06af98e6cd0d374dbe5969147ca3", size = 941208, upload-time = "2026-08-17T19:48:56.938Z" }, + { url = "https://files.pythonhosted.org/packages/50/53/ee1453623bf65f019328721ccb6587846d2c5b7b82f34e73ca09101f072e/tiktoken-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9c5fe393aab56469f04e432ff851216d3def3436cf5f07e442a240164bf500f", size = 1094198, upload-time = "2026-08-17T19:48:57.955Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/6448cfe278c3664ba9ec5b5ac08344341f7dc3d42888476e215a14eda2be/tiktoken-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbe2cc3bba939bcdaf103e03df9d5039d33887080b315624be28ec69059e5f94", size = 1038820, upload-time = "2026-08-17T19:48:59.015Z" }, + { url = "https://files.pythonhosted.org/packages/69/3b/d67eac1bcce9dee3abe23aff5e3ded3116bbebaf67b80a0811c06d3806fc/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2157f52e4b4d7ac5ecc7457b3716834706e7ef9a46f5144029bfeb7cf71f4e06", size = 1186175, upload-time = "2026-08-17T19:49:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/37/62/cae690d9783146b0f81f564ada0f8f611de68178c0c9c7e1e969f0516b48/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:26e60f6a956ee171ab728b37b8439905d7ea1db435c30f9822f291e9861c861d", size = 1203884, upload-time = "2026-08-17T19:49:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1e/633e30237b94e383cf814145499079f3bb9cdd4aeafc1bc42e01b0f810a6/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:380873f330b741c4435574f37edb20813d04603ace2d53e0a63560e1fec83010", size = 1250980, upload-time = "2026-08-17T19:49:02.274Z" }, + { url = "https://files.pythonhosted.org/packages/cb/56/4c12f07b812f84206f38d723eb1ebfdd34bad9309b5dbc0bee6bbcff4cbf/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3fd7c14b1cb45b486c39fc9b3443bb341f3e2fc7e6f31247f3435a5836651632", size = 1315434, upload-time = "2026-08-17T19:49:03.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e0/c65603f0c44811def666d3fbf611bf2af3b5e1ef613e06c19411419830b3/tiktoken-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:90a762670c7f968184723769a06ed51f5cf5ce5dcd1e30164f25c72d85c2d1f1", size = 940883, upload-time = "2026-08-17T19:49:04.583Z" }, + { url = "https://files.pythonhosted.org/packages/59/b0/1cf129f4af8fc513931f931023def596b7c4bfc77026513cd9d851da9e88/tiktoken-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e067f4cbcc5d036e8aff7fe7a6b530a8f4de2e4616ad9005a24a1879e24e6450", size = 1096273, upload-time = "2026-08-17T19:49:05.807Z" }, + { url = "https://files.pythonhosted.org/packages/62/85/2ae74575e321148484147e10b53c3b1717c59ebaa9edb4fe18b1f5c055f8/tiktoken-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f2af4a336ea56d6c14f27741a0e1d8294a35dd0b038bcf990d232ebb54eb994b", size = 1040269, upload-time = "2026-08-17T19:49:06.943Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/92a1120a12e4bcf2d5464350d1a91b68a433d63ce656bb7f806c27aec09c/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f702e0aeeb6506e57687e881c59e844ebe8f0a6a097ddafe20e3ab25f387be4e", size = 1186101, upload-time = "2026-08-17T19:49:08.102Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7d/144af98dc5ad68108451a82e2f5a17f80e2663f5115058b8dfd215c1ad02/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e3442bbb2f0c588cec876061e37ae67b455b9df9978b003c8fe30e45f2ef5b42", size = 1204457, upload-time = "2026-08-17T19:49:09.28Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1f/be7cb06ab2108f612f3e92e7b76cf391e192db0db37a984616f0cc32aafc/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:979c1524f753b662b0f3cd261b135afe6659cce33caaa7a5ea00dd1756b3055c", size = 1251716, upload-time = "2026-08-17T19:49:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6b/81f158d0f90adb826cd704069c2129a046cb784a2a09861009519fc41cf4/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2cc19ac87b41c9493c9778ff5847f0c8bbcf5bd0ec6b87ce06c1c802adc8a771", size = 1315432, upload-time = "2026-08-17T19:49:11.844Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/f5fa35ec13f07279fdcaf3cc9c04bbb154ea591d23978651f2b672593e8a/tiktoken-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:eceeff0c62419bc78d4b6e70a4762a4d25df3ae8f2d5946e3853ce93e7a57098", size = 988046, upload-time = "2026-08-17T19:49:13.282Z" }, + { url = "https://files.pythonhosted.org/packages/68/c9/7756717408d3d0dfea3f046c9466144b28afde39ff69d5808f2475dcd7f5/tiktoken-0.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6eb94895c45f26bb8f5546e5fd8a069efcf6e3f108ea9d5cbe3bf6f7f3983438", size = 1096261, upload-time = "2026-08-17T19:49:14.351Z" }, + { url = "https://files.pythonhosted.org/packages/79/29/46ad8061f57bd9f8b2ea0aa82bf574e0f2aa040b0857a1582adba9957899/tiktoken-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:86951a971c53979ec857bd8c4a32dc227ab0fd33f6c12a3bd62d3fbf5f0bfcaa", size = 1040183, upload-time = "2026-08-17T19:49:15.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7c/3184d17b868456f17b60b1a75f5ec0405618a43aa753336df341d8f11781/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e2eca764c53490f8930dbce329e0769f11108d87d908282a80c5c130e26e7037", size = 1186719, upload-time = "2026-08-17T19:49:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e8/46de4400d5bf859f640feee85bd7e32235f68ddf25db53c63be78e581e3a/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:26cc4b4840fa0e9f4b72ed489883e12f57e00d1021ca794720e3c29a12f0edef", size = 1204660, upload-time = "2026-08-17T19:49:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/af8964c38bc8226dd8950305b7a255fa33345d5572f78af7275a313d28e0/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2fc834fbe3f6a0736905c36ab709537e6840dbd63b982dc9e0216ae7d305ba1a", size = 1250932, upload-time = "2026-08-17T19:49:19.28Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4b/323631116fc986d9cc5bbeb2b8223c7c85e61a8bb94ea5ab4951023b149b/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ca4db6ff5c5bf600f9b7761a0070ed44dfe5797a76bd432fb978bc480ef40c58", size = 1315190, upload-time = "2026-08-17T19:49:20.467Z" }, + { url = "https://files.pythonhosted.org/packages/18/8b/ba48a73729c9270989b36f37ab2ed5525e52690d715097c9fa791aaa5d05/tiktoken-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7aab286a020660a039097912a088236b985d18a3090d73f136c4413d29d37ca0", size = 987717, upload-time = "2026-08-17T19:49:21.704Z" }, + { url = "https://files.pythonhosted.org/packages/1d/10/b73b7e319179e0f60b32475f783b044f9cece872c53b6662664e9084b0d0/tiktoken-0.14.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:14b47e3674f2624803a8acc8fb367b7e24fc53055f9df3296482fe9a3a34a232", size = 1096280, upload-time = "2026-08-17T19:49:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6b/09999a9bf1d559670d1680e8f8e419ac0e2c5f6aac82e9bfdf70f260b30a/tiktoken-0.14.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:19d643d701fdaa70e5b9c7f8f96abcaffe77ca5e482a3a1a7dde46feb4284695", size = 1040433, upload-time = "2026-08-17T19:49:23.998Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7b/8537be0836f3df99b2a636b44399bfa43cd757f2b8b4097dacb794cf24a7/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:e4ddf863b59347deaa92302dcd90e5eb003cdc9be06ec2b692c38d1bdd9efd49", size = 1186989, upload-time = "2026-08-17T19:49:25.021Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9d/f9c56d7a943a4468abf9ef37661bb9b8e0cd3aa8aa87368c7146cc3f3222/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:60c47ca69ddda0dea8256fffd12e1b86f4b59734a20e4a70c61f63cc5f021df4", size = 1204615, upload-time = "2026-08-17T19:49:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/98a38579db25c4a8a84e31dd95d9072ec5f21f7e70de591da0412e29b25b/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:728303a072163130c5b477b1f20d6211895569c1d5302c24ffc93a3009160871", size = 1251828, upload-time = "2026-08-17T19:49:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/0c/83/467be424746c039c5493c0f4102feab16b9b48eb6f5c089b2a2438e3cde2/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3c5349c9f916283bba32bec8af69b763e4faa304dc004d0eaaea66a3cf004c1f", size = 1316260, upload-time = "2026-08-17T19:49:29.101Z" }, + { url = "https://files.pythonhosted.org/packages/02/ee/ddf46ca78e371f5890e96b6e7d089a85b3536432be219851eb0481786ca8/tiktoken-0.14.0-cp315-cp315-win_amd64.whl", hash = "sha256:1b6e4adcfd285c44502aed51df98aaaca4f0fea028165dbf8a9e857b9f98d8ea", size = 988230, upload-time = "2026-08-17T19:49:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/2a/00/5162e90c851a28da18ed382d34898b79a8022548e5619a64e14c03ce7c3d/tiktoken-0.14.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:11d8211b290855d2721334ff17dd9b3a17bfb26872be01f25d73612ef7ece890", size = 1096186, upload-time = "2026-08-17T19:49:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/65/97/a5a7bfccf25b1bb65e82bae8edff11ac3c9c041c374b7b4a823d60c38133/tiktoken-0.14.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d0781223705199b289faa59601bb9c2441712d4c600dd13c43d8fd6a33d22cd5", size = 1039947, upload-time = "2026-08-17T19:49:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/ef427fc638f1439181c5e12dd26b70e881861f89c007aa7e5b36300f8342/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2ea70afba6b9eddbf22c165142e5f0a2ad7aa36a452873c48b57bb2aeb8492ae", size = 1186997, upload-time = "2026-08-17T19:49:34.121Z" }, + { url = "https://files.pythonhosted.org/packages/3e/88/2f3f85a968cdc514152129af0a060ebcccb067005a2f29b0d5ef3c838514/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:78571efc311c30b73f31eb949a921d6dac39a5d9dc42d1cfa8f8db157b3447b1", size = 1205211, upload-time = "2026-08-17T19:49:35.284Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f6/80760e98a08e6649d2d68afb6035af713121dfb615acce8c4f73810ec438/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86f66c85e796f5d05d5c4a60ec1d40cbfebc47a32464053528c797163fa9ab89", size = 1251479, upload-time = "2026-08-17T19:49:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/c5/84/50966fb6918a0fb9b32721277e5342bf729a2d74350074d662fbedf9772e/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:149d97453c4c98c04b081d64a85e635921269b532710d6faf81e9e82b790e7d3", size = 1316673, upload-time = "2026-08-17T19:49:37.756Z" }, + { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, +] + +[[package]] +name = "tld" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5d/76b4383ac4e5b5e254e50c09807b3e13820bed6d6c11cd540264988d6802/tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345", size = 467175, upload-time = "2026-03-06T23:50:34.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, +] + +[[package]] +name = "trafilatura" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "courlan" }, + { name = "htmldate" }, + { name = "justext" }, + { name = "lxml" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/e7/d359fcf887675c4305fb1f4548372484a2d8f0bc793dc6f2da3a748c1253/trafilatura-2.3.1.tar.gz", hash = "sha256:2e5557aeab32d33b9da7ff0debbb677a676c5b648a927e50d91b8277011e1105", size = 350794, upload-time = "2026-10-06T19:30:26.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/20/e95d135c417aceaa3d34d1b4dff9aceb85e98800480eca1b033933717169/trafilatura-2.3.1-py3-none-any.whl", hash = "sha256:f86bad2ee36f82884e14dee84c83aae0f0ac0127da5c65764e82f3846716b822", size = 158660, upload-time = "2026-10-06T19:30:24.385Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, +] + +[[package]] +name = "umap-learn" +version = "0.5.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba" }, + { name = "numpy" }, + { name = "pynndescent" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/ee/af4171241117f85c74b5ca6448ea1033cc28d599c13651d67289bacd4083/umap_learn-0.5.12.tar.gz", hash = "sha256:6aff02ecac5f2aad9f3c65ee518d7ae93e1a985ae38721fdcffceee4232c33c7", size = 96672, upload-time = "2026-04-08T20:03:54.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/98/f63318ccbe75c810011fe9233884c5d348d94d90005de1b79e5f93bef9c0/umap_learn-0.5.12-py3-none-any.whl", hash = "sha256:f2a85d2a2adcb52b541bed9b27a23ca169b56bb1b23283abeebfb8dfb8a42fe5", size = 91849, upload-time = "2026-04-08T20:03:52.561Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" }, + { url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" }, + { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" }, + { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" }, + { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" }, + { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" }, + { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" }, + { url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" }, + { url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" }, + { url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" }, + { url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" }, + { url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" }, +] + +[[package]] +name = "websockets" +version = "17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" }, + { url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" }, + { url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" }, + { url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" }, + { url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" }, + { url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" }, + { url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" }, + { url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" }, + { url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" }, + { url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" }, + { url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" }, + { url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" }, + { url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" }, + { url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" }, + { url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" }, + { url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" }, + { url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" }, + { url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" }, + { url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" }, + { url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" }, + { url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" }, + { url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" }, + { url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" }, + { url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" }, + { url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" }, +] From 215af0a090f7d743df2fb358022d140b9034b7dc Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Sun, 11 Oct 2026 12:07:26 -0400 Subject: [PATCH 03/16] Support multiple analyses instead of one global corpus - Schema v2: jobs -> analyses; articles, chunks and projections carry an analysis_id with ON DELETE CASCADE. Article ids hash analysis_id:url so the same URL can appear in several analyses. WAL + busy timeout so the pipeline thread and API reads don't block each other - One LanceDB table per analysis; drop the unused cluster_id column and its O(n^2) full-table rewrite on every run - Migration from v1 turns the existing corpus into one analysis (keyed by the latest finished job), backing up the DB and vectors to *.v1.bak. Handles early v1 databases without the jobs.warning column - Analyses left running by a server restart are marked as errors - Split main.py into backend/db, backend/pipeline and the routes - REST API under /api/analyses/{id}/...: list, get, delete (409 while running), stream, corpus, clusters, outliers, articles, chat, export. Pydantic response models so the frontend can generate a typed client - Errors use string `detail`; the frontend was reading a key that never existed, so every error showed as a bare "HTTP " - RAG raises instead of returning error text as an "answer"; chat maps missing corpus to 409 and model/embedding failures to 502 - Old frontend pointed at the new routes until the rebuild Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- backend/api/main.py | 635 ++++++++---------- backend/db/__init__.py | 166 +++++ backend/db/schema.sql | 50 +- backend/embedding/project.py | 26 +- backend/embedding/store.py | 167 ++--- backend/ingestion/extractor.py | 10 +- backend/pipeline.py | 158 +++++ backend/synthesis/rag.py | 42 +- frontend/src/components/ArticleSidebar.svelte | 3 +- frontend/src/components/ChatPanel.svelte | 4 +- frontend/src/lib/api.js | 47 +- frontend/src/routes/+page.svelte | 25 +- tests/conftest.py | 44 ++ tests/test_analyses_api.py | 101 +++ tests/test_migration.py | 109 +++ tests/test_quality_fixes.py | 45 +- 17 files changed, 1051 insertions(+), 587 deletions(-) create mode 100644 backend/db/__init__.py create mode 100644 backend/pipeline.py create mode 100644 tests/conftest.py create mode 100644 tests/test_analyses_api.py create mode 100644 tests/test_migration.py diff --git a/README.md b/README.md index dc32675..cc04199 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Then open [http://localhost:5173](http://localhost:5173) in your browser. - **Consensus** — most common terms per cluster, via TF-IDF - **Ask** — full-width chat interface for natural language queries against the indexed corpus; answers render as formatted markdown with cited sources 4. Click any point on the cluster map to open the source article. Click a cluster label to filter the outliers sidebar. Use the **Ask** tab to query the corpus with natural language -5. Click **New analysis** in the top bar to start over with a different topic. You'll be asked to confirm — this clears the current corpus entirely before the new pipeline runs +5. Click **New analysis** in the top bar to start a different topic. Every analysis is kept in the database (`GET /api/analyses` lists them); the current UI shows one at a time 6. Click **Export** in the top bar to download an Obsidian-compatible zip — a Canvas file with all articles plotted at their UMAP positions, and a vault folder with one Markdown note per article --- @@ -116,6 +116,6 @@ Avoid topics that are too broad (`AI`, `climate change`) or too narrow (fewer th **Embeddings are slow** — You're running Ollama on CPU. Switch to OpenAI embeddings by setting `EMBEDDING_MODE=openai` and `OPENAI_API_KEY=your_key` in `.env`. -**Chat returns "No corpus indexed yet"** — The analysis hasn't completed, or no articles passed the quality filter. Try re-running with a different topic. +**Chat says "This analysis has no indexed corpus yet"** — The analysis hasn't completed, or no articles passed the quality filter. Try re-running with a different topic. -**Second analysis returns stale data** — This should no longer happen. Each new job deletes all rows from `articles`, `chunks`, `projections` and drops the LanceDB vectors table before running. If you see stale data, stop the backend, delete `data/discourse.db` and `data/vectors/`, and restart. +**Upgrading from an older version** — On first start the backend migrates the old single-corpus database into one analysis. It copies `data/discourse.db` and `data/vectors/` to `*.v1.bak` first; delete those once you're happy. diff --git a/backend/api/main.py b/backend/api/main.py index 8c2ce31..04f2fe0 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -1,5 +1,4 @@ import asyncio -import hashlib import io import json as json_lib import re @@ -9,13 +8,17 @@ import uuid import zipfile from contextlib import asynccontextmanager -from pathlib import Path +from typing import Literal -from fastapi import BackgroundTasks, FastAPI, HTTPException +from fastapi import BackgroundTasks, FastAPI, HTTPException, Response from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field from backend.config import settings +from backend.db import RUNNING_STATUSES, connect, init_db +from backend.embedding.store import drop_embeddings +from backend.pipeline import run_pipeline if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) @@ -23,7 +26,6 @@ @asynccontextmanager async def lifespan(_app: FastAPI): - Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True) init_db() yield @@ -40,40 +42,137 @@ async def lifespan(_app: FastAPI): # --------------------------------------------------------------------------- -# DB helpers +# Models # --------------------------------------------------------------------------- -def get_db() -> sqlite3.Connection: - conn = sqlite3.connect(settings.db_path) - conn.row_factory = sqlite3.Row - return conn +AnalysisStatus = Literal[ + "queued", "crawling", "processing", "embedding", "projecting", "done", "error" +] -def init_db() -> None: - schema = (Path(__file__).parent.parent / "db" / "schema.sql").read_text() - conn = get_db() - conn.executescript(schema) - try: - conn.execute("ALTER TABLE jobs ADD COLUMN warning TEXT") - conn.commit() - except sqlite3.OperationalError: - pass # column already exists (pre-existing db file) - conn.commit() - conn.close() +class AnalysisCreate(BaseModel): + topic: str = Field(min_length=1, max_length=500) -def update_job(job_id: str, **kwargs) -> None: - kwargs["updated_ts"] = int(time.time()) - sets = ", ".join(f"{k}=?" for k in kwargs) - vals = list(kwargs.values()) + [job_id] - conn = get_db() - conn.execute(f"UPDATE jobs SET {sets} WHERE id=?", vals) - conn.commit() - conn.close() +class Analysis(BaseModel): + id: str + topic: str + status: AnalysisStatus + progress: int + article_count: int + error_msg: str | None + warning: str | None + created_ts: int + updated_ts: int + + +class CorpusPoint(BaseModel): + chunk_id: str + article_id: str + child_text: str | None + url: str + title: str | None + domain: str | None + publish_date: str | None + x: float + y: float + cluster_id: int + cluster_label: str | None + distance_from_centroid: float + word_count: int | None + + +class Cluster(BaseModel): + cluster_id: int + cluster_label: str | None + article_count: int + + +class Outlier(BaseModel): + chunk_id: str + cluster_id: int + cluster_label: str | None + distance_from_centroid: float + article_id: str + title: str | None + domain: str | None + publish_date: str | None + + +class Article(BaseModel): + id: str + analysis_id: str + url: str + domain: str | None + title: str | None + publish_date: str | None + crawl_ts: int | None + word_count: int | None + language: str | None + raw_text: str | None + status: str + + +class ChatRequest(BaseModel): + query: str = Field(min_length=1, max_length=2000) + + +class Citation(BaseModel): + url: str + title: str + domain: str + + +class ChatResponse(BaseModel): + answer: str + citations: list[Citation] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_analysis(conn: sqlite3.Connection, analysis_id: str) -> sqlite3.Row: + row = conn.execute("SELECT * FROM analyses WHERE id=?", (analysis_id,)).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Analysis not found") + return row + + +def fetch_outliers( + conn: sqlite3.Connection, analysis_id: str, cluster_id: int | None = None, limit: int = 50 +) -> list[dict]: + """One row per article — its chunk furthest from its cluster centroid — + ordered by that distance, optionally restricted to one cluster.""" + rows = conn.execute( + """ + SELECT chunk_id, cluster_id, cluster_label, distance_from_centroid, + article_id, title, domain, publish_date + FROM ( + SELECT p.chunk_id, p.cluster_id, p.cluster_label, + p.distance_from_centroid, + c.article_id, a.title, a.domain, a.publish_date, + ROW_NUMBER() OVER ( + PARTITION BY c.article_id + ORDER BY p.distance_from_centroid DESC + ) AS rn + FROM projections p + JOIN chunks c ON c.id = p.chunk_id + JOIN articles a ON a.id = c.article_id + WHERE p.analysis_id = :aid AND a.status = 'ok' + AND (:cid IS NULL OR p.cluster_id = :cid) + ) + WHERE rn = 1 + ORDER BY distance_from_centroid DESC + LIMIT :limit + """, + {"aid": analysis_id, "cid": cluster_id, "limit": limit}, + ).fetchall() + return [dict(r) for r in rows] # --------------------------------------------------------------------------- -# Endpoints +# Analyses # --------------------------------------------------------------------------- @app.get("/api/health") @@ -81,153 +180,193 @@ def health(): return {"status": "ok"} -class JobRequest(BaseModel): - topic: str +@app.post("/api/analyses", status_code=201) +def create_analysis(req: AnalysisCreate, background_tasks: BackgroundTasks) -> Analysis: + analysis_id = str(uuid.uuid4()) + now = int(time.time()) + topic = req.topic.strip() + conn = connect() + try: + conn.execute( + "INSERT INTO analyses (id, topic, status, progress, created_ts, updated_ts) " + "VALUES (?,?,?,?,?,?)", + (analysis_id, topic, "queued", 0, now, now), + ) + conn.commit() + row = _get_analysis(conn, analysis_id) + finally: + conn.close() + background_tasks.add_task(run_pipeline, analysis_id, topic) + return Analysis(**dict(row)) -@app.post("/api/jobs") -def create_job(req: JobRequest, background_tasks: BackgroundTasks): - job_id = str(uuid.uuid4()) - now = int(time.time()) - conn = get_db() - conn.execute( - "INSERT INTO jobs (id, topic, status, progress, created_ts, updated_ts) VALUES (?,?,?,?,?,?)", - (job_id, req.topic, "queued", 0, now, now), - ) - conn.commit() - conn.close() - background_tasks.add_task(run_pipeline, job_id, req.topic) - return {"job_id": job_id} +@app.get("/api/analyses") +def list_analyses() -> list[Analysis]: + conn = connect() + try: + rows = conn.execute("SELECT * FROM analyses ORDER BY created_ts DESC").fetchall() + finally: + conn.close() + return [Analysis(**dict(r)) for r in rows] -@app.get("/api/corpus") -def get_corpus(): - conn = get_db() +@app.get("/api/analyses/{analysis_id}") +def get_analysis(analysis_id: str) -> Analysis: + conn = connect() try: + return Analysis(**dict(_get_analysis(conn, analysis_id))) + finally: + conn.close() + + +@app.delete("/api/analyses/{analysis_id}", status_code=204) +def delete_analysis(analysis_id: str) -> Response: + conn = connect() + try: + row = _get_analysis(conn, analysis_id) + if row["status"] in RUNNING_STATUSES: + raise HTTPException(status_code=409, detail="Analysis is still running") + conn.execute("DELETE FROM analyses WHERE id=?", (analysis_id,)) + conn.commit() + finally: + conn.close() + drop_embeddings(analysis_id) + return Response(status_code=204) + + +@app.get("/api/analyses/{analysis_id}/stream") +async def stream_analysis(analysis_id: str): + conn = connect() + try: + _get_analysis(conn, analysis_id) + finally: + conn.close() + + async def event_generator(): + last_payload = None + while True: + conn = connect() + try: + row = conn.execute( + "SELECT * FROM analyses WHERE id=?", (analysis_id,) + ).fetchone() + finally: + conn.close() + + if row is None: # deleted while streaming + yield f"event: error\ndata: {json_lib.dumps({'detail': 'Analysis not found'})}\n\n" + return + + payload = Analysis(**dict(row)).model_dump_json() + if payload != last_payload: + yield f"data: {payload}\n\n" + last_payload = payload + + if row["status"] not in RUNNING_STATUSES: + return + + await asyncio.sleep(1) + + return StreamingResponse(event_generator(), media_type="text/event-stream") + + +# --------------------------------------------------------------------------- +# Analysis data +# --------------------------------------------------------------------------- + +@app.get("/api/analyses/{analysis_id}/corpus") +def get_corpus(analysis_id: str) -> list[CorpusPoint]: + conn = connect() + try: + _get_analysis(conn, analysis_id) rows = conn.execute( """ - SELECT - p.chunk_id, - c.article_id, - c.child_text, - a.url, - a.title, - a.domain, - a.publish_date, - p.x, - p.y, - p.cluster_id, - p.cluster_label, - p.distance_from_centroid, - a.word_count + SELECT p.chunk_id, c.article_id, c.child_text, a.url, a.title, a.domain, + a.publish_date, p.x, p.y, p.cluster_id, p.cluster_label, + p.distance_from_centroid, a.word_count FROM projections p JOIN chunks c ON c.id = p.chunk_id JOIN articles a ON a.id = c.article_id - WHERE a.status = 'ok' + WHERE p.analysis_id = ? AND a.status = 'ok' ORDER BY p.cluster_id, p.distance_from_centroid - """ + """, + (analysis_id,), ).fetchall() finally: conn.close() - return [dict(r) for r in rows] + return [CorpusPoint(**dict(r)) for r in rows] -@app.get("/api/clusters") -def get_clusters(): - conn = get_db() +@app.get("/api/analyses/{analysis_id}/clusters") +def get_clusters(analysis_id: str) -> list[Cluster]: + conn = connect() try: + _get_analysis(conn, analysis_id) rows = conn.execute( """ SELECT p.cluster_id, p.cluster_label, COUNT(DISTINCT c.article_id) AS article_count FROM projections p JOIN chunks c ON c.id = p.chunk_id + WHERE p.analysis_id = ? GROUP BY p.cluster_id, p.cluster_label ORDER BY p.cluster_id - """ + """, + (analysis_id,), ).fetchall() finally: conn.close() - return [dict(r) for r in rows] + return [Cluster(**dict(r)) for r in rows] -def fetch_outliers( - conn: sqlite3.Connection, cluster_id: int | None = None, limit: int = 50 -) -> list[dict]: - """One row per article — its chunk furthest from its cluster centroid — - ordered by that distance, optionally restricted to one cluster.""" - rows = conn.execute( - """ - SELECT chunk_id, cluster_id, cluster_label, distance_from_centroid, - article_id, title, domain, publish_date - FROM ( - SELECT p.chunk_id, p.cluster_id, p.cluster_label, - p.distance_from_centroid, - c.article_id, a.title, a.domain, a.publish_date, - ROW_NUMBER() OVER ( - PARTITION BY c.article_id - ORDER BY p.distance_from_centroid DESC - ) AS rn - FROM projections p - JOIN chunks c ON c.id = p.chunk_id - JOIN articles a ON a.id = c.article_id - WHERE a.status = 'ok' AND (:cid IS NULL OR p.cluster_id = :cid) - ) - WHERE rn = 1 - ORDER BY distance_from_centroid DESC - LIMIT :limit - """, - {"cid": cluster_id, "limit": limit}, - ).fetchall() - return [dict(r) for r in rows] - - -@app.get("/api/outliers") -def get_outliers(cluster_id: int | None = None): - conn = get_db() +@app.get("/api/analyses/{analysis_id}/outliers") +def get_outliers(analysis_id: str, cluster_id: int | None = None) -> list[Outlier]: + conn = connect() try: - return fetch_outliers(conn, cluster_id) + _get_analysis(conn, analysis_id) + return [Outlier(**r) for r in fetch_outliers(conn, analysis_id, cluster_id)] finally: conn.close() -@app.get("/api/articles/{article_id}") -def get_article(article_id: str): - conn = get_db() +@app.get("/api/analyses/{analysis_id}/articles/{article_id}") +def get_article(analysis_id: str, article_id: str) -> Article: + conn = connect() try: row = conn.execute( - "SELECT * FROM articles WHERE id = ?", (article_id,) + "SELECT * FROM articles WHERE id = ? AND analysis_id = ?", (article_id, analysis_id) ).fetchone() finally: conn.close() if row is None: - raise HTTPException(status_code=404, detail={"error": "Article not found"}) - return dict(row) - - -class ChatRequest(BaseModel): - query: str + raise HTTPException(status_code=404, detail="Article not found") + return Article(**dict(row)) -@app.post("/api/chat") -def chat(req: ChatRequest): - from backend.synthesis.rag import query_corpus - return query_corpus(req.query) +@app.post("/api/analyses/{analysis_id}/chat") +def chat(analysis_id: str, req: ChatRequest) -> ChatResponse: + from backend.synthesis.rag import NoCorpusError, query_corpus + conn = connect() + try: + _get_analysis(conn, analysis_id) + finally: + conn.close() + try: + return ChatResponse(**query_corpus(analysis_id, req.query)) + except NoCorpusError as e: + raise HTTPException(status_code=409, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=502, detail=f"Chat failed: {e}") from e -@app.get("/api/export") -def export_obsidian(): - from fastapi.responses import StreamingResponse +@app.get("/api/analyses/{analysis_id}/export") +def export_obsidian(analysis_id: str): from backend.export import build_canvas, build_vault_files - conn = get_db() + conn = connect() try: - job_row = conn.execute( - "SELECT topic FROM jobs WHERE status='done' ORDER BY updated_ts DESC LIMIT 1" - ).fetchone() - topic = job_row["topic"] if job_row else "discourse" - + topic = _get_analysis(conn, analysis_id)["topic"] rows = conn.execute( """ SELECT @@ -239,10 +378,11 @@ def export_obsidian(): FROM projections p JOIN chunks c ON c.id = p.chunk_id JOIN articles a ON a.id = c.article_id - WHERE a.status = 'ok' + WHERE p.analysis_id = ? AND a.status = 'ok' GROUP BY a.id, a.title, a.url, a.domain, a.publish_date, a.word_count ORDER BY MIN(p.cluster_id) - """ + """, + (analysis_id,), ).fetchall() rows = [dict(r) for r in rows] @@ -252,26 +392,23 @@ def export_obsidian(): FROM projections p JOIN chunks c ON c.id = p.chunk_id JOIN articles a ON a.id = c.article_id - WHERE a.status = 'ok' + WHERE p.analysis_id = ? AND a.status = 'ok' GROUP BY p.cluster_label ORDER BY MIN(p.cluster_id) - """ + """, + (analysis_id,), ).fetchall() clusters = [dict(r) for r in clusters] finally: conn.close() if not rows: - raise HTTPException( - status_code=404, - detail={"error": "No corpus found. Run an analysis first."}, - ) + raise HTTPException(status_code=404, detail="This analysis has no corpus to export.") canvas = build_canvas(rows) vault_files = build_vault_files(rows, clusters, topic) slug = re.sub(r"[^a-z0-9]+", "-", topic.lower()).strip("-")[:40] - zip_name = f"discourse-{slug}.zip" folder = f"discourse-{slug}" buf = io.BytesIO() @@ -287,227 +424,5 @@ def export_obsidian(): return StreamingResponse( buf, media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, + headers={"Content-Disposition": f'attachment; filename="{folder}.zip"'}, ) - - -@app.get("/api/jobs/{job_id}") -def get_job(job_id: str): - conn = get_db() - row = conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone() - conn.close() - if row is None: - raise HTTPException( - status_code=404, - detail={"error": "Job not found", "code": "NOT_FOUND"}, - ) - return dict(row) - - -@app.get("/api/jobs/{job_id}/stream") -async def stream_job(job_id: str): - from fastapi.responses import StreamingResponse - - async def event_generator(): - last_payload = None - while True: - conn = get_db() - row = conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone() - conn.close() - - if row is None: - yield f"event: error\ndata: {json_lib.dumps({'error': 'Job not found', 'code': 'NOT_FOUND'})}\n\n" - return - - payload = json_lib.dumps(dict(row)) - if payload != last_payload: - yield f"data: {payload}\n\n" - last_payload = payload - - if row["status"] in ("done", "error"): - return - - await asyncio.sleep(1) - - return StreamingResponse(event_generator(), media_type="text/event-stream") - - -# --------------------------------------------------------------------------- -# Ingestion pipeline (background task) -# --------------------------------------------------------------------------- - -async def run_pipeline(job_id: str, topic: str) -> None: - """Full ingestion + processing pipeline: search → crawl → extract → chunk → dedup.""" - from backend.ingestion.crawler import crawl_urls - from backend.ingestion.extractor import extract_article - from backend.ingestion.search import collect_urls - from backend.processing.dedup import chunk_and_dedup - from backend.processing.metadata import extract_publish_date - - try: - # Clear previous corpus before starting fresh - conn = get_db() - conn.execute("DELETE FROM projections") - conn.execute("DELETE FROM chunks") - conn.execute("DELETE FROM articles") - conn.commit() - conn.close() - from backend.embedding.store import clear_embeddings - clear_embeddings() - - # Step 1: generate queries and search Brave - update_job(job_id, status="crawling", progress=5) - urls = await asyncio.to_thread(collect_urls, topic) - - # Step 2: Playwright scrape all URLs — runs in a thread with a fresh event loop - update_job(job_id, progress=20) - crawled = await asyncio.to_thread(crawl_urls, urls) - - # Step 3: extract clean text, filter, persist to SQLite - update_job(job_id, status="processing", progress=60) - - def _extract_and_store() -> tuple[int, list[dict]]: - ok_count = 0 - ok_articles: list[dict] = [] - conn = sqlite3.connect(settings.db_path) - try: - for item in crawled: - url = item["url"] - html = item["html"] - try: - article = extract_article(url, html) - if article is None: - aid = hashlib.sha256(url.encode()).hexdigest()[:16] - conn.execute( - "INSERT OR IGNORE INTO articles " - "(id, url, status, crawl_ts) VALUES (?,?,?,?)", - (aid, url, "filtered", int(time.time())), - ) - continue - - publish_date = extract_publish_date(html, url) - conn.execute( - "INSERT OR IGNORE INTO articles " - "(id, url, domain, title, publish_date, crawl_ts, " - " word_count, language, raw_text, status) " - "VALUES (?,?,?,?,?,?,?,?,?,?)", - ( - article["id"], article["url"], article["domain"], - article["title"], publish_date, article["crawl_ts"], - article["word_count"], article["language"], - article["raw_text"], "ok", - ), - ) - ok_count += 1 - ok_articles.append({"id": article["id"], "raw_text": article["raw_text"]}) - except Exception: - continue # individual failure — log and keep going - conn.commit() - finally: - conn.close() - return ok_count, ok_articles - - ok_count, ok_articles = await asyncio.to_thread(_extract_and_store) - - # Step 4: chunk + dedup all successfully extracted articles - update_job(job_id, progress=75) - - def _chunk_and_dedup(articles: list[dict]) -> None: - chunks = chunk_and_dedup(articles) - conn = sqlite3.connect(settings.db_path) - try: - conn.executemany( - "INSERT OR IGNORE INTO chunks " - "(id, article_id, parent_id, parent_text, child_text, " - " chunk_index, token_count, is_duplicate) " - "VALUES (:id, :article_id, :parent_id, :parent_text, :child_text, " - " :chunk_index, :token_count, :is_duplicate)", - chunks, - ) - conn.commit() - finally: - conn.close() - - await asyncio.to_thread(_chunk_and_dedup, ok_articles) - - # Step 5: embed non-duplicate child chunks - update_job(job_id, status="embedding", progress=80) - - def _embed_and_store() -> None: - from backend.embedding.embed import embed_chunks - from backend.embedding.store import write_embeddings - - conn = sqlite3.connect(settings.db_path) - conn.row_factory = sqlite3.Row - try: - rows = conn.execute( - "SELECT c.id, c.child_text, c.article_id, " - " a.domain, a.publish_date " - "FROM chunks c " - "JOIN articles a ON a.id = c.article_id " - "WHERE c.is_duplicate = 0" - ).fetchall() - finally: - conn.close() - - if not rows: - return - - chunks_for_embed = [ - { - "id": r["id"], - "child_text": r["child_text"], - "article_id": r["article_id"], - "domain": r["domain"] or "", - "publish_date": r["publish_date"] or "", - } - for r in rows - ] - - embeddings = embed_chunks(chunks_for_embed, kind="document") - - # Build full rows for LanceDB (merge embed result with metadata) - meta_by_id = {c["id"]: c for c in chunks_for_embed} - lance_rows = [] - for e in embeddings: - m = meta_by_id[e["chunk_id"]] - lance_rows.append( - { - "chunk_id": e["chunk_id"], - "article_id": m["article_id"], - "vector": e["vector"], - "child_text": m["child_text"], - "domain": m["domain"], - "publish_date": m["publish_date"], - "cluster_id": -1, - } - ) - write_embeddings(lance_rows) - - await asyncio.to_thread(_embed_and_store) - - # Step 6: UMAP + k-means + cluster labels → projections table - update_job(job_id, status="projecting", progress=90) - - def _project() -> None: - from backend.embedding.project import run_projection - run_projection(db_path=settings.db_path) - - await asyncio.to_thread(_project) - - warning = None - if ok_count < 30: - warning = ( - f"Only {ok_count} articles were successfully indexed. " - "Results may be sparse — try a broader or more general topic." - ) - - update_job( - job_id, status="done", progress=100, - article_count=ok_count, warning=warning, - ) - - except Exception as e: - import traceback - traceback.print_exc() - update_job(job_id, status="error", error_msg=str(e)) diff --git a/backend/db/__init__.py b/backend/db/__init__.py new file mode 100644 index 0000000..c508ad7 --- /dev/null +++ b/backend/db/__init__.py @@ -0,0 +1,166 @@ +""" +SQLite access: connections, schema creation and migration. + +Every connection enables foreign keys (so deleting an analysis cascades) and a +busy timeout, and the database runs in WAL mode so a pipeline writing in a +worker thread does not block API reads. +""" + +from __future__ import annotations + +import logging +import shutil +import sqlite3 +import time +from pathlib import Path + +from backend.config import settings + +log = logging.getLogger(__name__) + +SCHEMA_VERSION = 2 +_SCHEMA = Path(__file__).parent / "schema.sql" + +# Analyses still in one of these states are being worked on by a pipeline. +RUNNING_STATUSES = ("queued", "crawling", "processing", "embedding", "projecting") + + +def connect(db_path: str | None = None) -> sqlite3.Connection: + conn = sqlite3.connect(db_path or settings.db_path, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db(db_path: str | None = None) -> None: + """Create or migrate the schema, then fail any analysis a restart interrupted.""" + path = db_path or settings.db_path + Path(path).parent.mkdir(parents=True, exist_ok=True) + conn = connect(path) + try: + conn.execute("PRAGMA journal_mode = WAL") + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version < SCHEMA_VERSION and _table_exists(conn, "jobs"): + conn.close() + _migrate_v1_to_v2(path) + conn = connect(path) + else: + conn.executescript(_SCHEMA.read_text()) + conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + placeholders = ",".join("?" for _ in RUNNING_STATUSES) + conn.execute( + f"UPDATE analyses SET status='error', error_msg=?, updated_ts=? " + f"WHERE status IN ({placeholders})", + ("Interrupted by a server restart", int(time.time()), *RUNNING_STATUSES), + ) + conn.commit() + finally: + conn.close() + + +def _table_exists(conn: sqlite3.Connection, name: str) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,) + ).fetchone() + return row is not None + + +def _migrate_v1_to_v2(path: str) -> None: + """ + v1 had a single global corpus plus a `jobs` table. The corpus becomes one + analysis, keyed by the most recent finished job. The old database file and + vector directory are kept alongside as backups. + """ + from backend.embedding import store + + backup = f"{path}.v1.bak" + shutil.copy2(path, backup) + if Path(settings.lance_path).exists(): + shutil.copytree(settings.lance_path, f"{settings.lance_path}.v1.bak", dirs_exist_ok=True) + log.info("Migrating %s to schema v2 (backup at %s)", path, backup) + + conn = connect(path) + try: + has_corpus = conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0] > 0 + job = conn.execute( + "SELECT * FROM jobs WHERE status='done' ORDER BY updated_ts DESC LIMIT 1" + ).fetchone() + # Older v1 databases lack columns that were added later (e.g. `warning`). + job = dict(job) if job else {} + analysis_id = None + if has_corpus: + now = int(time.time()) + analysis_id = job.get("id", "imported-corpus") + analysis = { + "id": analysis_id, + "topic": job.get("topic") or "Imported corpus", + "status": "done", + "progress": 100, + "article_count": job.get("article_count") or 0, + "warning": job.get("warning"), + "created_ts": job.get("created_ts") or now, + "updated_ts": job.get("updated_ts") or now, + } + # Copy vectors first: non-destructive, and re-runnable if we crash + # before the SQL migration commits. + store.adopt_legacy_table(analysis_id) + + conn.execute("PRAGMA foreign_keys = OFF") + conn.execute("BEGIN") + for table in ("articles", "chunks", "projections"): + conn.execute(f"ALTER TABLE {table} RENAME TO {table}_v1") + for statement in _SCHEMA.read_text().split(";"): + if statement.strip(): + conn.execute(statement) + + if analysis_id: + conn.execute( + "INSERT INTO analyses (id, topic, status, progress, article_count, warning, " + "created_ts, updated_ts) VALUES (:id, :topic, :status, :progress, " + ":article_count, :warning, :created_ts, :updated_ts)", + analysis, + ) + conn.execute( + "INSERT INTO articles (id, analysis_id, url, domain, title, publish_date, " + "crawl_ts, word_count, language, raw_text, status) " + "SELECT id, ?, url, domain, title, publish_date, crawl_ts, word_count, " + "language, raw_text, status FROM articles_v1", + (analysis_id,), + ) + conn.execute( + "INSERT INTO chunks (id, analysis_id, article_id, parent_id, parent_text, " + "child_text, chunk_index, token_count, is_duplicate) " + "SELECT id, ?, article_id, parent_id, parent_text, child_text, chunk_index, " + "token_count, is_duplicate FROM chunks_v1", + (analysis_id,), + ) + conn.execute( + "INSERT INTO projections (chunk_id, analysis_id, x, y, cluster_id, " + "cluster_label, distance_from_centroid) " + "SELECT chunk_id, ?, x, y, cluster_id, cluster_label, distance_from_centroid " + "FROM projections_v1 WHERE chunk_id IN (SELECT id FROM chunks)", + (analysis_id,), + ) + + for table in ("projections_v1", "chunks_v1", "articles_v1", "jobs"): + conn.execute(f"DROP TABLE {table}") + conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + store.drop_legacy_table() + + +def update_analysis(analysis_id: str, **fields) -> None: + fields["updated_ts"] = int(time.time()) + sets = ", ".join(f"{k}=?" for k in fields) + conn = connect() + try: + conn.execute(f"UPDATE analyses SET {sets} WHERE id=?", [*fields.values(), analysis_id]) + conn.commit() + finally: + conn.close() diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 4b52832..2e283f7 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -1,7 +1,25 @@ --- Articles: one row per crawled URL +-- Schema version 2 (PRAGMA user_version = 2): every row belongs to an analysis. +-- Deleting an analysis cascades to its articles, chunks and projections +-- (requires PRAGMA foreign_keys = ON, set by backend.db.connect). + +-- Analyses: one row per topic run (formerly "jobs") +CREATE TABLE IF NOT EXISTS analyses ( + id TEXT PRIMARY KEY, -- uuid4 + topic TEXT NOT NULL, + status TEXT NOT NULL, -- queued | crawling | processing | embedding | projecting | done | error + progress INTEGER DEFAULT 0, -- 0-100 + article_count INTEGER DEFAULT 0, + error_msg TEXT, + warning TEXT, -- non-fatal warning, e.g. low article yield + created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL +); + +-- Articles: one row per crawled URL per analysis CREATE TABLE IF NOT EXISTS articles ( - id TEXT PRIMARY KEY, -- sha256(url)[:16] - url TEXT NOT NULL UNIQUE, + id TEXT PRIMARY KEY, -- sha256(analysis_id:url)[:16] + analysis_id TEXT NOT NULL REFERENCES analyses(id) ON DELETE CASCADE, + url TEXT NOT NULL, domain TEXT, title TEXT, publish_date TEXT, -- ISO date string, nullable @@ -9,13 +27,16 @@ CREATE TABLE IF NOT EXISTS articles ( word_count INTEGER, language TEXT DEFAULT 'en', raw_text TEXT, - status TEXT DEFAULT 'ok' -- ok | filtered | error + status TEXT DEFAULT 'ok', -- ok | filtered | error + UNIQUE (analysis_id, url) ); +CREATE INDEX IF NOT EXISTS idx_articles_analysis ON articles(analysis_id); -- Chunks: one row per child chunk CREATE TABLE IF NOT EXISTS chunks ( id TEXT PRIMARY KEY, -- {article_id}#p{i}c{j} - article_id TEXT REFERENCES articles(id), + analysis_id TEXT NOT NULL REFERENCES analyses(id) ON DELETE CASCADE, + article_id TEXT REFERENCES articles(id) ON DELETE CASCADE, parent_id TEXT, -- {article_id}#p{i} parent_text TEXT, -- full parent chunk text (for RAG context) child_text TEXT, @@ -23,26 +44,17 @@ CREATE TABLE IF NOT EXISTS chunks ( token_count INTEGER, is_duplicate INTEGER DEFAULT 0 ); +CREATE INDEX IF NOT EXISTS idx_chunks_analysis ON chunks(analysis_id); +CREATE INDEX IF NOT EXISTS idx_chunks_article ON chunks(article_id); -- Projections: 2D UMAP coords + cluster info per chunk CREATE TABLE IF NOT EXISTS projections ( - chunk_id TEXT PRIMARY KEY REFERENCES chunks(id), + chunk_id TEXT PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE, + analysis_id TEXT NOT NULL REFERENCES analyses(id) ON DELETE CASCADE, x REAL, y REAL, cluster_id INTEGER, cluster_label TEXT, -- LLM-generated 2-3 word label distance_from_centroid REAL ); - --- Jobs: track ingestion pipeline state -CREATE TABLE IF NOT EXISTS jobs ( - id TEXT PRIMARY KEY, -- uuid4 - topic TEXT, - status TEXT, -- queued | crawling | processing | embedding | projecting | done | error - progress INTEGER DEFAULT 0, -- 0-100 - article_count INTEGER DEFAULT 0, - error_msg TEXT, - warning TEXT, -- non-fatal warning, e.g. low article yield - created_ts INTEGER, - updated_ts INTEGER -); +CREATE INDEX IF NOT EXISTS idx_projections_analysis ON projections(analysis_id); diff --git a/backend/embedding/project.py b/backend/embedding/project.py index 3dae629..a770e71 100644 --- a/backend/embedding/project.py +++ b/backend/embedding/project.py @@ -2,7 +2,7 @@ UMAP projection + k-means clustering + cluster labelling. Steps: -1. Load all vectors from LanceDB. +1. Load the analysis's vectors from LanceDB. 2. Run UMAP (2D). 3. Try k-means for k in {5, 7, 10}; pick best silhouette score. 4. For each chunk compute Euclidean distance to its cluster centroid. @@ -13,15 +13,14 @@ from __future__ import annotations -import sqlite3 - import numpy as np from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score from umap import UMAP from backend.config import settings -from backend.embedding.store import read_all_embeddings, update_cluster_ids +from backend.db import connect +from backend.embedding.store import read_all_embeddings # --------------------------------------------------------------------------- # Helpers @@ -67,14 +66,12 @@ def _label_cluster(cluster_id: int, top_chunks: list[str]) -> str: # Main entry point # --------------------------------------------------------------------------- -def run_projection(db_path: str | None = None) -> None: +def run_projection(analysis_id: str) -> None: """ Full projection pipeline. Writes rows to the SQLite projections table. """ - _db_path = db_path or settings.db_path - # 1. Load embeddings - rows = read_all_embeddings() + rows = read_all_embeddings(analysis_id) if not rows: raise RuntimeError("No embeddings found in LanceDB — run embedding step first.") @@ -111,20 +108,17 @@ def run_projection(db_path: str | None = None) -> None: except Exception: cluster_text_labels[cid] = f"cluster {cid}" - # 6. Update LanceDB cluster_ids - chunk_cluster_map = {chunk_ids[i]: int(cluster_labels_arr[i]) for i in range(len(chunk_ids))} - update_cluster_ids(chunk_cluster_map) - - # 7. Write projections to SQLite - conn = sqlite3.connect(_db_path) + # 6. Write projections to SQLite + conn = connect() try: conn.executemany( "INSERT OR REPLACE INTO projections " - "(chunk_id, x, y, cluster_id, cluster_label, distance_from_centroid) " - "VALUES (?, ?, ?, ?, ?, ?)", + "(chunk_id, analysis_id, x, y, cluster_id, cluster_label, distance_from_centroid) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", [ ( chunk_ids[i], + analysis_id, float(coords_2d[i, 0]), float(coords_2d[i, 1]), int(cluster_labels_arr[i]), diff --git a/backend/embedding/store.py b/backend/embedding/store.py index c019bb4..6f8c714 100644 --- a/backend/embedding/store.py +++ b/backend/embedding/store.py @@ -1,8 +1,8 @@ """ -LanceDB read/write for embeddings. +LanceDB read/write for embeddings — one table per analysis. -Table schema (per PLANNING.md §4.2): - chunk_id, article_id, vector, child_text, domain, publish_date, cluster_id +Table schema: + chunk_id, article_id, vector, child_text, domain, publish_date """ from __future__ import annotations @@ -12,7 +12,18 @@ from backend.config import settings -TABLE_NAME = "embeddings" +# Single global table used before analyses existed (schema v1). +LEGACY_TABLE_NAME = "embeddings" + +_COLUMNS = ["chunk_id", "article_id", "vector", "child_text", "domain", "publish_date"] + + +def table_name(analysis_id: str) -> str: + return "emb_" + analysis_id.replace("-", "") + + +def _connect() -> lancedb.DBConnection: + return lancedb.connect(settings.lance_path) def table_exists(db: lancedb.DBConnection, name: str) -> bool: @@ -27,120 +38,82 @@ def table_exists(db: lancedb.DBConnection, name: str) -> bool: return False -def _get_table(db: lancedb.DBConnection) -> lancedb.table.Table | None: - if table_exists(db, TABLE_NAME): - return db.open_table(TABLE_NAME) - return None +def open_table(analysis_id: str) -> lancedb.table.Table | None: + db = _connect() + name = table_name(analysis_id) + return db.open_table(name) if table_exists(db, name) else None + + +def _schema(dim: int) -> pa.Schema: + return pa.schema( + [ + pa.field("chunk_id", pa.string()), + pa.field("article_id", pa.string()), + pa.field("vector", pa.list_(pa.float32(), dim)), + pa.field("child_text", pa.string()), + pa.field("domain", pa.string()), + pa.field("publish_date", pa.string()), + ] + ) -def write_embeddings(rows: list[dict]) -> None: +def write_embeddings(analysis_id: str, rows: list[dict]) -> None: """ - rows: list of dicts with keys: - chunk_id, article_id, vector, child_text, domain, publish_date, cluster_id - Creates or appends to the LanceDB embeddings table. + rows: dicts with chunk_id, article_id, vector, child_text, domain, publish_date. + Creates the analysis's table or appends to it. """ if not rows: return - - db = lancedb.connect(settings.lance_path) - table = _get_table(db) - + normalised = _normalise(rows) + table = open_table(analysis_id) if table is None: - # Infer dim from first row - dim = len(rows[0]["vector"]) - schema = pa.schema( - [ - pa.field("chunk_id", pa.string()), - pa.field("article_id", pa.string()), - pa.field("vector", pa.list_(pa.float32(), dim)), - pa.field("child_text", pa.string()), - pa.field("domain", pa.string()), - pa.field("publish_date", pa.string()), - pa.field("cluster_id", pa.int32()), - ] + _connect().create_table( + table_name(analysis_id), data=normalised, schema=_schema(len(rows[0]["vector"])) ) - # Normalise rows to correct types - normalised = _normalise(rows) - db.create_table(TABLE_NAME, data=normalised, schema=schema) else: - normalised = _normalise(rows) table.add(normalised) -def read_all_embeddings() -> list[dict]: - """Return all rows from the embeddings table as a list of dicts.""" - db = lancedb.connect(settings.lance_path) - table = _get_table(db) +def read_all_embeddings(analysis_id: str) -> list[dict]: + """Return all rows of the analysis's table as a list of dicts.""" + table = open_table(analysis_id) if table is None: return [] return table.to_pandas().to_dict(orient="records") -def update_cluster_ids(chunk_cluster_map: dict[str, int]) -> None: - """ - Update the cluster_id column for each chunk_id. - LanceDB doesn't support in-place updates; we reload and overwrite. - """ - if not chunk_cluster_map: - return +def drop_embeddings(analysis_id: str) -> None: + db = _connect() + name = table_name(analysis_id) + if table_exists(db, name): + db.drop_table(name) - db = lancedb.connect(settings.lance_path) - table = _get_table(db) - if table is None: + +def adopt_legacy_table(analysis_id: str) -> None: + """Copy the pre-analyses global table into the analysis's own table (idempotent).""" + db = _connect() + if not table_exists(db, LEGACY_TABLE_NAME): return + data = db.open_table(LEGACY_TABLE_NAME).to_arrow().select(_COLUMNS) + db.create_table(table_name(analysis_id), data=data, mode="overwrite") - df = table.to_pandas() - df["cluster_id"] = df["chunk_id"].map( - lambda cid: chunk_cluster_map.get(cid, df.loc[df["chunk_id"] == cid, "cluster_id"].iloc[0]) - ) - # Overwrite table - db.drop_table(TABLE_NAME) - dim = len(df["vector"].iloc[0]) - schema = pa.schema( - [ - pa.field("chunk_id", pa.string()), - pa.field("article_id", pa.string()), - pa.field("vector", pa.list_(pa.float32(), dim)), - pa.field("child_text", pa.string()), - pa.field("domain", pa.string()), - pa.field("publish_date", pa.string()), - pa.field("cluster_id", pa.int32()), - ] - ) - records = [] - for _, row in df.iterrows(): - records.append({ - "chunk_id": str(row["chunk_id"]), - "article_id": str(row["article_id"]), - "vector": [float(v) for v in row["vector"]], - "child_text": str(row["child_text"]), - "domain": str(row["domain"]) if row["domain"] else "", - "publish_date": str(row["publish_date"]) if row["publish_date"] else "", - "cluster_id": int(row["cluster_id"]) if row["cluster_id"] is not None else -1, - }) - db.create_table(TABLE_NAME, data=records, schema=schema) - - -def clear_embeddings() -> None: - """Drop and recreate the embeddings table, clearing all vectors.""" - db = lancedb.connect(settings.lance_path) - if table_exists(db, TABLE_NAME): - db.drop_table(TABLE_NAME) + +def drop_legacy_table() -> None: + db = _connect() + if table_exists(db, LEGACY_TABLE_NAME): + db.drop_table(LEGACY_TABLE_NAME) def _normalise(rows: list[dict]) -> list[dict]: """Ensure all fields have correct Python types for Arrow ingestion.""" - out = [] - for r in rows: - out.append( - { - "chunk_id": str(r.get("chunk_id", "")), - "article_id": str(r.get("article_id", "")), - "vector": [float(v) for v in r["vector"]], - "child_text": str(r.get("child_text", "")), - "domain": str(r.get("domain", "") or ""), - "publish_date": str(r.get("publish_date", "") or ""), - "cluster_id": int(r.get("cluster_id", -1)), - } - ) - return out + return [ + { + "chunk_id": str(r.get("chunk_id", "")), + "article_id": str(r.get("article_id", "")), + "vector": [float(v) for v in r["vector"]], + "child_text": str(r.get("child_text", "")), + "domain": str(r.get("domain", "") or ""), + "publish_date": str(r.get("publish_date", "") or ""), + } + for r in rows + ] diff --git a/backend/ingestion/extractor.py b/backend/ingestion/extractor.py index 782e70f..54fccf8 100644 --- a/backend/ingestion/extractor.py +++ b/backend/ingestion/extractor.py @@ -6,7 +6,12 @@ from langdetect import LangDetectException, detect -def extract_article(url: str, html: str) -> dict | None: +def article_id(analysis_id: str, url: str) -> str: + """Stable per-analysis id, so the same URL can appear in several analyses.""" + return hashlib.sha256(f"{analysis_id}:{url}".encode()).hexdigest()[:16] + + +def extract_article(analysis_id: str, url: str, html: str) -> dict | None: """ Extract clean article text from raw HTML using trafilatura. Returns an article dict, or None if the article fails filtering: @@ -37,10 +42,9 @@ def extract_article(url: str, html: str) -> dict | None: meta = trafilatura.extract_metadata(html, default_url=url) title = meta.title if meta and meta.title else None domain = urlparse(url).netloc - article_id = hashlib.sha256(url.encode()).hexdigest()[:16] return { - "id": article_id, + "id": article_id(analysis_id, url), "url": url, "domain": domain, "title": title, diff --git a/backend/pipeline.py b/backend/pipeline.py new file mode 100644 index 0000000..4d43c24 --- /dev/null +++ b/backend/pipeline.py @@ -0,0 +1,158 @@ +""" +The analysis pipeline: search → crawl → extract → chunk/dedup → embed → project. + +Each run writes only rows tagged with its own analysis_id, so analyses are +independent and several can exist side by side. +""" + +from __future__ import annotations + +import asyncio +import logging +import time + +from backend.db import connect, update_analysis + +log = logging.getLogger(__name__) + +LOW_YIELD_THRESHOLD = 30 + + +async def run_pipeline(analysis_id: str, topic: str) -> None: + from backend.embedding.project import run_projection + from backend.ingestion.crawler import crawl_urls + from backend.ingestion.search import collect_urls + + try: + # Step 1: generate queries and search + update_analysis(analysis_id, status="crawling", progress=5) + urls = await asyncio.to_thread(collect_urls, topic) + + # Step 2: Playwright scrape — runs in a thread with its own event loop + update_analysis(analysis_id, progress=20) + crawled = await asyncio.to_thread(crawl_urls, urls) + + # Step 3: extract clean text, filter, persist + update_analysis(analysis_id, status="processing", progress=60) + ok_articles = await asyncio.to_thread(_extract_and_store, analysis_id, crawled) + + # Step 4: chunk + dedup across the whole corpus + update_analysis(analysis_id, progress=75) + await asyncio.to_thread(_chunk_and_store, analysis_id, ok_articles) + + # Step 5: embed non-duplicate child chunks + update_analysis(analysis_id, status="embedding", progress=80) + await asyncio.to_thread(_embed_and_store, analysis_id) + + # Step 6: UMAP + clustering + labels → projections + update_analysis(analysis_id, status="projecting", progress=90) + await asyncio.to_thread(run_projection, analysis_id) + + ok_count = len(ok_articles) + warning = None + if ok_count < LOW_YIELD_THRESHOLD: + warning = ( + f"Only {ok_count} articles were successfully indexed. " + "Results may be sparse — try a broader or more general topic." + ) + update_analysis( + analysis_id, status="done", progress=100, article_count=ok_count, warning=warning + ) + except Exception as e: + log.exception("Pipeline failed for analysis %s", analysis_id) + update_analysis(analysis_id, status="error", error_msg=str(e)) + + +def _extract_and_store(analysis_id: str, crawled: list[dict]) -> list[dict]: + from backend.ingestion.extractor import article_id, extract_article + from backend.processing.metadata import extract_publish_date + + ok_articles: list[dict] = [] + conn = connect() + try: + for item in crawled: + url, html = item["url"], item["html"] + try: + article = extract_article(analysis_id, url, html) + if article is None: + conn.execute( + "INSERT OR IGNORE INTO articles (id, analysis_id, url, status, crawl_ts) " + "VALUES (?,?,?,?,?)", + (article_id(analysis_id, url), analysis_id, url, "filtered", + int(time.time())), + ) + continue + + conn.execute( + "INSERT OR IGNORE INTO articles " + "(id, analysis_id, url, domain, title, publish_date, crawl_ts, " + " word_count, language, raw_text, status) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?)", + ( + article["id"], analysis_id, article["url"], article["domain"], + article["title"], extract_publish_date(html, url), article["crawl_ts"], + article["word_count"], article["language"], article["raw_text"], "ok", + ), + ) + ok_articles.append({"id": article["id"], "raw_text": article["raw_text"]}) + except Exception: + log.warning("Extraction failed for %s", url, exc_info=True) + conn.commit() + finally: + conn.close() + return ok_articles + + +def _chunk_and_store(analysis_id: str, articles: list[dict]) -> None: + from backend.processing.dedup import chunk_and_dedup + + chunks = [{**c, "analysis_id": analysis_id} for c in chunk_and_dedup(articles)] + conn = connect() + try: + conn.executemany( + "INSERT OR IGNORE INTO chunks " + "(id, analysis_id, article_id, parent_id, parent_text, child_text, " + " chunk_index, token_count, is_duplicate) " + "VALUES (:id, :analysis_id, :article_id, :parent_id, :parent_text, :child_text, " + " :chunk_index, :token_count, :is_duplicate)", + chunks, + ) + conn.commit() + finally: + conn.close() + + +def _embed_and_store(analysis_id: str) -> None: + from backend.embedding.embed import embed_chunks + from backend.embedding.store import write_embeddings + + conn = connect() + try: + rows = conn.execute( + "SELECT c.id, c.child_text, c.article_id, a.domain, a.publish_date " + "FROM chunks c JOIN articles a ON a.id = c.article_id " + "WHERE c.analysis_id = ? AND c.is_duplicate = 0", + (analysis_id,), + ).fetchall() + finally: + conn.close() + if not rows: + return + + chunks = [ + { + "id": r["id"], + "child_text": r["child_text"], + "article_id": r["article_id"], + "domain": r["domain"] or "", + "publish_date": r["publish_date"] or "", + } + for r in rows + ] + embeddings = embed_chunks(chunks, kind="document") + meta_by_id = {c["id"]: c for c in chunks} + write_embeddings( + analysis_id, + [{**meta_by_id[e["chunk_id"]], "chunk_id": e["chunk_id"], "vector": e["vector"]} + for e in embeddings], + ) diff --git a/backend/synthesis/rag.py b/backend/synthesis/rag.py index 7b3aac9..0e2d49c 100644 --- a/backend/synthesis/rag.py +++ b/backend/synthesis/rag.py @@ -8,15 +8,20 @@ 4. Deduplicate by article (max one excerpt per article). 5. Assemble context string and call Claude. 6. Return {"answer": str, "citations": [{"url", "title", "domain"}]}. + +Errors propagate to the caller; an analysis with no vectors raises NoCorpusError. """ from __future__ import annotations -import sqlite3 - from backend.config import settings +from backend.db import connect +from backend.embedding.embed import embed_chunks +from backend.embedding.store import open_table -TABLE_NAME = "embeddings" + +class NoCorpusError(LookupError): + """The analysis has no indexed vectors to search.""" def order_and_dedupe_by_article(rows: list[dict], ranked_chunk_ids: list[str]) -> list[dict]: @@ -35,38 +40,25 @@ def order_and_dedupe_by_article(rows: list[dict], ranked_chunk_ids: list[str]) - return deduped -def query_corpus(query: str, db_path: str | None = None, top_k: int | None = None) -> dict: - _db_path = db_path or settings.db_path +def query_corpus(analysis_id: str, query: str, top_k: int | None = None) -> dict: _top_k = top_k or settings.rag_top_k + table = open_table(analysis_id) + if table is None: + raise NoCorpusError("This analysis has no indexed corpus yet.") + # --- 1. Embed the query --- - try: - from backend.embedding.embed import embed_chunks - embedded = embed_chunks([{"id": "query", "child_text": query}], kind="query") - query_vector = embedded[0]["vector"] - except Exception as e: - return {"answer": f"Embedding error: {e}", "citations": []} + query_vector = embed_chunks([{"id": "query", "child_text": query}], kind="query")[0]["vector"] # --- 2. Search LanceDB --- - try: - import lancedb - db = lancedb.connect(settings.lance_path) - from backend.embedding.store import table_exists - if not table_exists(db, TABLE_NAME): - return {"answer": "No corpus indexed yet. Please run an analysis first.", "citations": []} - table = db.open_table(TABLE_NAME) - results_df = table.search(query_vector).limit(_top_k).to_pandas() - except Exception as e: - return {"answer": f"Vector search error: {e}", "citations": []} - + results_df = table.search(query_vector).limit(_top_k).to_pandas() if results_df.empty: - return {"answer": "No corpus indexed yet. Please run an analysis first.", "citations": []} + raise NoCorpusError("This analysis has no indexed corpus yet.") chunk_ids = results_df["chunk_id"].tolist() # --- 3. Fetch parent_text + article metadata from SQLite --- - conn = sqlite3.connect(_db_path) - conn.row_factory = sqlite3.Row + conn = connect() try: placeholders = ",".join("?" for _ in chunk_ids) rows = conn.execute( diff --git a/frontend/src/components/ArticleSidebar.svelte b/frontend/src/components/ArticleSidebar.svelte index 722bc09..a2d23e0 100644 --- a/frontend/src/components/ArticleSidebar.svelte +++ b/frontend/src/components/ArticleSidebar.svelte @@ -1,6 +1,7 @@ - - - - - - diff --git a/frontend/src/components/ChatPanel.svelte b/frontend/src/components/ChatPanel.svelte deleted file mode 100644 index 319277c..0000000 --- a/frontend/src/components/ChatPanel.svelte +++ /dev/null @@ -1,328 +0,0 @@ - - -
-
- {#each PRESET_QUERIES as q} - - {/each} -
- -
- {#if messages.length === 0 && !loading} -
Ask a question about the indexed corpus.
- {/if} - {#each messages as msg} - {#if msg.role === 'user'} -
{msg.text}
- {:else} -
-
{@html renderMd(msg.text)}
- {#if msg.citations && msg.citations.length > 0} -
-
Sources
- {#each msg.citations as c} - - {c.title || c.domain} - {c.domain} - - {/each} -
- {/if} -
- {/if} - {/each} - {#if loading} -
Searching corpus…
- {/if} - {#if error} -
{error}
- - {/if} -
- -
- - -
-
- - diff --git a/frontend/src/components/ClusterMap.svelte b/frontend/src/components/ClusterMap.svelte deleted file mode 100644 index b74df54..0000000 --- a/frontend/src/components/ClusterMap.svelte +++ /dev/null @@ -1,217 +0,0 @@ - - -
- {#if articles.length === 0} -
No data — run an analysis first
- {/if} -
- -{#if tooltip.show && tooltip.data} -
-
{tooltip.data.title || 'Untitled'}
-
{tooltip.data.domain || ''}{tooltip.data.publish_date ? ' · ' + tooltip.data.publish_date : ''}
-
-{/if} - - diff --git a/frontend/src/components/ConsensusBars.svelte b/frontend/src/components/ConsensusBars.svelte deleted file mode 100644 index d0b0eaf..0000000 --- a/frontend/src/components/ConsensusBars.svelte +++ /dev/null @@ -1,177 +0,0 @@ - - -
- {#if clusters.length === 0} -
No data — run an analysis first
- {:else} -
- {#each clusters as cluster (cluster.id)} - {@const active = selectedCluster == null || selectedCluster === cluster.id} -
onSelectCluster(selectedCluster === cluster.id ? null : cluster.id)} - on:keydown={(e) => e.key === 'Enter' && onSelectCluster(selectedCluster === cluster.id ? null : cluster.id)} - > -
- {cluster.label.toUpperCase()} -
-
- {#each cluster.terms as t (t.term)} -
- {t.term} -
-
-
-
- {/each} -
-
- {/each} -
- {/if} -
- - diff --git a/frontend/src/components/OutlierFinder.svelte b/frontend/src/components/OutlierFinder.svelte deleted file mode 100644 index 524beac..0000000 --- a/frontend/src/components/OutlierFinder.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - -
- {#if items.length === 0} -
No articles yet — run an analysis first
- {:else} - {#each items as item (item.chunk_id)} - - {/each} - {/if} -
- - diff --git a/frontend/src/components/TemporalDrift.svelte b/frontend/src/components/TemporalDrift.svelte deleted file mode 100644 index 6bd5448..0000000 --- a/frontend/src/components/TemporalDrift.svelte +++ /dev/null @@ -1,248 +0,0 @@ - - -
- {#if articles.length === 0} -
No data — run an analysis first
- {:else if months.length === 0} -
No date information in corpus
- {/if} -
- -{#if months.length > 0} -
- - - {formatMonth(activeMonth) || '—'} -
-{/if} - - diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js deleted file mode 100644 index f1dec5a..0000000 --- a/frontend/src/lib/api.js +++ /dev/null @@ -1,53 +0,0 @@ -const BASE = 'http://localhost:8001'; - -async function errorMessage(res) { - try { - const body = await res.json(); - if (typeof body.detail === 'string') return body.detail; - } catch { /* not JSON */ } - return `HTTP ${res.status}`; -} - -async function apiFetch(path, options = {}) { - const res = await fetch(`${BASE}${path}`, options); - if (!res.ok) throw new Error(await errorMessage(res)); - return res.json(); -} - -const a = (id) => `/api/analyses/${encodeURIComponent(id)}`; - -export const api = { - createAnalysis: (topic) => apiFetch('/api/analyses', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic }), - }), - getAnalysis: (id) => apiFetch(a(id)), - streamAnalysis: (id, onUpdate, onError) => { - const source = new EventSource(`${BASE}${a(id)}/stream`); - source.onmessage = (e) => onUpdate(JSON.parse(e.data)); - source.addEventListener('error', (e) => { - if (e.data) { - try { onError?.(JSON.parse(e.data)); } catch { /* ignore parse errors */ } - } - }); - source.onerror = () => { /* connection dropped or stream closed by server */ }; - return source; - }, - getCorpus: (id) => apiFetch(`${a(id)}/corpus`), - getClusters: (id) => apiFetch(`${a(id)}/clusters`), - getOutliers: (id, clusterId = null) => apiFetch( - `${a(id)}/outliers${clusterId != null ? `?cluster_id=${clusterId}` : ''}` - ), - getArticle: (id, articleId) => apiFetch(`${a(id)}/articles/${encodeURIComponent(articleId)}`), - chat: (id, query) => apiFetch(`${a(id)}/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - }), - exportZip: async (id) => { - const res = await fetch(`${BASE}${a(id)}/export`); - if (!res.ok) throw new Error(await errorMessage(res)); - return res.blob(); - }, -}; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..1e0a013 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,85 @@ +/** + * Thin wrapper over the generated client (./gen, from the backend's OpenAPI + * schema): every call returns its data or throws an ApiError carrying the + * backend's `detail` message and HTTP status. + */ +import { client } from './api/gen/client.gen'; +import * as sdk from './api/gen/sdk.gen'; +import type { Analysis } from './api/gen/types.gen'; + +export type { + Analysis, + AnalysisEvent, + Article, + ChatResponse, + Citation, + CorpusPoint +} from './api/gen/types.gen'; + +// Same origin in production (FastAPI serves the app); Vite proxies /api in dev. +client.setConfig({ baseUrl: '' }); + +export type Fetch = typeof fetch; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = 'ApiError'; + } +} + +type Result = { data?: T; error?: unknown; response?: Response }; + +async function unwrap(call: Promise>): Promise { + let result: Result; + try { + result = await call; + } catch (e) { + throw new ApiError( + `Can't reach the Discourse Lens server. Is the backend running? (${(e as Error).message})`, + 0 + ); + } + const status = result.response?.status ?? 0; + if (result.error !== undefined || !result.response?.ok) { + throw new ApiError(detailOf(result.error) ?? `Request failed (HTTP ${status})`, status); + } + return result.data as T; +} + +function detailOf(error: unknown): string | undefined { + if (error && typeof error === 'object' && 'detail' in error) { + const detail = (error as { detail: unknown }).detail; + if (typeof detail === 'string') return detail; + // FastAPI validation errors: [{ msg, loc, ... }] + if (Array.isArray(detail) && detail[0]?.msg) return String(detail[0].msg); + } + return undefined; +} + +const path = (analysis_id: string) => ({ path: { analysis_id } }); + +export const api = { + listAnalyses: (fetch?: Fetch) => unwrap(sdk.listAnalyses({ fetch })), + createAnalysis: (topic: string) => unwrap(sdk.createAnalysis({ body: { topic } })), + getAnalysis: (id: string, fetch?: Fetch) => unwrap(sdk.getAnalysis({ ...path(id), fetch })), + deleteAnalysis: (id: string) => unwrap(sdk.deleteAnalysis(path(id))), + cancelAnalysis: (id: string) => unwrap(sdk.cancelAnalysis(path(id))), + getCorpus: (id: string, fetch?: Fetch) => unwrap(sdk.getCorpus({ ...path(id), fetch })), + getArticle: (id: string, articleId: string) => + unwrap(sdk.getArticle({ path: { analysis_id: id, article_id: articleId } })), + chat: (id: string, query: string) => unwrap(sdk.chat({ ...path(id), body: { query } })) +}; + +export const RUNNING: ReadonlyArray = [ + 'queued', + 'crawling', + 'processing', + 'embedding', + 'projecting' +]; + +export const isRunning = (a: Pick) => RUNNING.includes(a.status); diff --git a/frontend/src/lib/api/gen/client.gen.ts b/frontend/src/lib/api/gen/client.gen.ts new file mode 100644 index 0000000..eee936b --- /dev/null +++ b/frontend/src/lib/api/gen/client.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config +) => Config & T>; + +export const client: Client = createClient(createConfig()); diff --git a/frontend/src/lib/api/gen/client/client.gen.ts b/frontend/src/lib/api/gen/client/client.gen.ts new file mode 100644 index 0000000..5243298 --- /dev/null +++ b/frontend/src/lib/api/gen/client/client.gen.ts @@ -0,0 +1,277 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams +} from './utils.gen'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string + >( + options: RequestOptions + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined + }; + + if (opts.security) { + await setAuthParams(opts); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client['request'] = async (options) => { + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; + + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts) + }; + + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError; + } catch (error) { + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); + } + } + + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response + }; + } + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE') + }, + trace: makeMethodFn('TRACE') + } as Client; +}; diff --git a/frontend/src/lib/api/gen/client/index.ts b/frontend/src/lib/api/gen/client/index.ts new file mode 100644 index 0000000..2ece92c --- /dev/null +++ b/frontend/src/lib/api/gen/client/index.ts @@ -0,0 +1,27 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export type { ServerSentEventsResult } from '../core/serverSentEvents.gen'; +export type { ClientMeta } from '../core/types.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/frontend/src/lib/api/gen/client/types.gen.ts b/frontend/src/lib/api/gen/client/types.gen.ts new file mode 100644 index 0000000..9331d25 --- /dev/null +++ b/frontend/src/lib/api/gen/client/types.gen.ts @@ -0,0 +1,215 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { ServerSentEventsOptions, ServerSentEventsResult } from '../core/serverSentEvents.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string +> extends RequestOptions { + headers: Headers; + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields' +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields' +>( + options: Omit, 'method'> +) => RequestResult; + +type SseFn = < + TData = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields' +>( + options: Omit, 'method'> +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields' +>( + options: Omit, 'method'> & + Pick>, 'method'> +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + } +>( + options: TData & Options +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields' +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/frontend/src/lib/api/gen/client/utils.gen.ts b/frontend/src/lib/api/gen/client/utils.gen.ts new file mode 100644 index 0000000..d6bc9ff --- /dev/null +++ b/frontend/src/lib/api/gen/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { + const querySerializer = (queryParams: T): string => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export async function setAuthParams( + options: Pick & { + headers: Headers; + } +): Promise { + for (const auth of options.security ?? []) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +} + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string) + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, + options: Options +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>() +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form' + }, + object: { + explode: true, + style: 'deepObject' + } +}); + +const defaultHeaders = { + 'Content-Type': 'application/json' +}; + +export const createConfig = ( + override: Config & T> = {} +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override +}); diff --git a/frontend/src/lib/api/gen/core/auth.gen.ts b/frontend/src/lib/api/gen/core/auth.gen.ts new file mode 100644 index 0000000..29b1c54 --- /dev/null +++ b/frontend/src/lib/api/gen/core/auth.gen.ts @@ -0,0 +1,48 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken +): Promise => { + const token = typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/frontend/src/lib/api/gen/core/bodySerializer.gen.ts b/frontend/src/lib/api/gen/core/bodySerializer.gen.ts new file mode 100644 index 0000000..7cbe186 --- /dev/null +++ b/frontend/src/lib/api/gen/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + } +}; + +export const jsonBodySerializer = { + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)) +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + } +}; diff --git a/frontend/src/lib/api/gen/core/params.gen.ts b/frontend/src/lib/api/gen/core/params.gen.ts new file mode 100644 index 0000000..514c217 --- /dev/null +++ b/frontend/src/lib/api/gen/core/params.gen.ts @@ -0,0 +1,178 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query' +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +} + +interface Params { + body?: unknown; + headers: Record; + path: Record; + query: Record; +} + +function stripEmptySlots(params: Params): void { + for (const [slot, value] of Object.entries(params)) { + if (slot === 'body') continue; + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +} + +export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { + const params: Params = { + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null) + }; + + const map = buildKeyMap(fields); + + function writeSlot(slot: Slot, key: string, value: unknown): void { + let record = params[slot] as Record | undefined; + if (record === undefined) { + record = Object.create(null) as Record; + params[slot] = record; + } + record[key] = value; + } + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + writeSlot(field.in, name, arg); + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + writeSlot(field.in, name, value); + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + writeSlot(slot, key.slice(prefix.length), value); + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + writeSlot(slot as Slot, key, value); + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +} diff --git a/frontend/src/lib/api/gen/core/pathSerializer.gen.ts b/frontend/src/lib/api/gen/core/pathSerializer.gen.ts new file mode 100644 index 0000000..cb7334e --- /dev/null +++ b/frontend/src/lib/api/gen/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value +}: SerializeOptions & { + value: unknown[]; +}): string => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value +}: SerializePrimitiveParam): string => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.' + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}): string => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string + }) + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/frontend/src/lib/api/gen/core/queryKeySerializer.gen.ts b/frontend/src/lib/api/gen/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..d7f354b --- /dev/null +++ b/frontend/src/lib/api/gen/core/queryKeySerializer.gen.ts @@ -0,0 +1,112 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/frontend/src/lib/api/gen/core/serverSentEvents.gen.ts b/frontend/src/lib/api/gen/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..127e9a9 --- /dev/null +++ b/frontend/src/lib/api/gen/core/serverSentEvents.gen.ts @@ -0,0 +1,242 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export function createSseClient({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +} diff --git a/frontend/src/lib/api/gen/core/types.gen.ts b/frontend/src/lib/api/gen/core/types.gen.ts new file mode 100644 index 0000000..9bff595 --- /dev/null +++ b/frontend/src/lib/api/gen/core/types.gen.ts @@ -0,0 +1,102 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; + +export type HttpMethod = + 'connect' | 'delete' | 'get' | 'head' | 'options' | 'patch' | 'post' | 'put' | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +/** + * Arbitrary metadata passed through the `meta` request option. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ClientMeta {} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/frontend/src/lib/api/gen/core/utils.gen.ts b/frontend/src/lib/api/gen/core/utils.gen.ts new file mode 100644 index 0000000..bb787af --- /dev/null +++ b/frontend/src/lib/api/gen/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true + }) + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string + })}` + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string) + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}): string => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}): unknown { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/frontend/src/lib/api/gen/index.ts b/frontend/src/lib/api/gen/index.ts new file mode 100644 index 0000000..a4453ab --- /dev/null +++ b/frontend/src/lib/api/gen/index.ts @@ -0,0 +1,97 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { + cancelAnalysis, + chat, + createAnalysis, + deleteAnalysis, + exportObsidian, + getAnalysis, + getArticle, + getClusters, + getCorpus, + getEvents, + getOutliers, + health, + listAnalyses, + type Options, + streamAnalysis +} from './sdk.gen'; +export type { + Analysis, + AnalysisCreate, + AnalysisEvent, + Article, + CancelAnalysisData, + CancelAnalysisError, + CancelAnalysisErrors, + CancelAnalysisResponse, + CancelAnalysisResponses, + ChatData, + ChatError, + ChatErrors, + ChatRequest, + ChatResponse, + ChatResponse2, + ChatResponses, + Citation, + ClientOptions, + Cluster, + CorpusPoint, + CreateAnalysisData, + CreateAnalysisError, + CreateAnalysisErrors, + CreateAnalysisResponse, + CreateAnalysisResponses, + DeleteAnalysisData, + DeleteAnalysisError, + DeleteAnalysisErrors, + DeleteAnalysisResponse, + DeleteAnalysisResponses, + ExportObsidianData, + ExportObsidianError, + ExportObsidianErrors, + ExportObsidianResponses, + GetAnalysisData, + GetAnalysisError, + GetAnalysisErrors, + GetAnalysisResponse, + GetAnalysisResponses, + GetArticleData, + GetArticleError, + GetArticleErrors, + GetArticleResponse, + GetArticleResponses, + GetClustersData, + GetClustersError, + GetClustersErrors, + GetClustersResponse, + GetClustersResponses, + GetCorpusData, + GetCorpusError, + GetCorpusErrors, + GetCorpusResponse, + GetCorpusResponses, + GetEventsData, + GetEventsError, + GetEventsErrors, + GetEventsResponse, + GetEventsResponses, + GetOutliersData, + GetOutliersError, + GetOutliersErrors, + GetOutliersResponse, + GetOutliersResponses, + HealthData, + HealthResponses, + HttpValidationError, + ListAnalysesData, + ListAnalysesResponse, + ListAnalysesResponses, + Outlier, + StreamAnalysisData, + StreamAnalysisError, + StreamAnalysisErrors, + StreamAnalysisResponses, + ValidationError +} from './types.gen'; diff --git a/frontend/src/lib/api/gen/sdk.gen.ts b/frontend/src/lib/api/gen/sdk.gen.ts new file mode 100644 index 0000000..4c45945 --- /dev/null +++ b/frontend/src/lib/api/gen/sdk.gen.ts @@ -0,0 +1,233 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; +import { client } from './client.gen'; +import type { + CancelAnalysisData, + CancelAnalysisErrors, + CancelAnalysisResponses, + ChatData, + ChatErrors, + ChatResponses, + CreateAnalysisData, + CreateAnalysisErrors, + CreateAnalysisResponses, + DeleteAnalysisData, + DeleteAnalysisErrors, + DeleteAnalysisResponses, + ExportObsidianData, + ExportObsidianErrors, + ExportObsidianResponses, + GetAnalysisData, + GetAnalysisErrors, + GetAnalysisResponses, + GetArticleData, + GetArticleErrors, + GetArticleResponses, + GetClustersData, + GetClustersErrors, + GetClustersResponses, + GetCorpusData, + GetCorpusErrors, + GetCorpusResponses, + GetEventsData, + GetEventsErrors, + GetEventsResponses, + GetOutliersData, + GetOutliersErrors, + GetOutliersResponses, + HealthData, + HealthResponses, + ListAnalysesData, + ListAnalysesResponses, + StreamAnalysisData, + StreamAnalysisErrors, + StreamAnalysisResponses +} from './types.gen'; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown +> = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: keyof ClientMeta extends never ? Record : ClientMeta; +}; + +/** + * Health + */ +export const health = ( + options?: Options +): RequestResult => + (options?.client ?? client).get({ + url: '/api/health', + ...options + }); + +/** + * List Analyses + */ +export const listAnalyses = ( + options?: Options +): RequestResult => + (options?.client ?? client).get({ + url: '/api/analyses', + ...options + }); + +/** + * Create Analysis + */ +export const createAnalysis = ( + options: Options +): RequestResult => + (options.client ?? client).post({ + url: '/api/analyses', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + +/** + * Delete Analysis + */ +export const deleteAnalysis = ( + options: Options +): RequestResult => + (options.client ?? client).delete({ + url: '/api/analyses/{analysis_id}', + ...options + }); + +/** + * Get Analysis + */ +export const getAnalysis = ( + options: Options +): RequestResult => + (options.client ?? client).get({ + url: '/api/analyses/{analysis_id}', + ...options + }); + +/** + * Cancel Analysis + * + * Ask a running analysis to stop; it reaches status 'cancelled' shortly after. + */ +export const cancelAnalysis = ( + options: Options +): RequestResult => + (options.client ?? client).post({ + url: '/api/analyses/{analysis_id}/cancel', + ...options + }); + +/** + * Get Events + */ +export const getEvents = ( + options: Options +): RequestResult => + (options.client ?? client).get({ + url: '/api/analyses/{analysis_id}/events', + ...options + }); + +/** + * Stream Analysis + * + * Server-sent events until the analysis stops running: + * default `message` events — the Analysis row, whenever it changes + * `log` events (with `id:`) — new rows from the event log; EventSource + * resends Last-Event-ID on reconnect, so the log resumes where it left off. + */ +export const streamAnalysis = ( + options: Options +): RequestResult => + (options.client ?? client).get({ + url: '/api/analyses/{analysis_id}/stream', + ...options + }); + +/** + * Get Corpus + */ +export const getCorpus = ( + options: Options +): RequestResult => + (options.client ?? client).get({ + url: '/api/analyses/{analysis_id}/corpus', + ...options + }); + +/** + * Get Clusters + */ +export const getClusters = ( + options: Options +): RequestResult => + (options.client ?? client).get({ + url: '/api/analyses/{analysis_id}/clusters', + ...options + }); + +/** + * Get Outliers + */ +export const getOutliers = ( + options: Options +): RequestResult => + (options.client ?? client).get({ + url: '/api/analyses/{analysis_id}/outliers', + ...options + }); + +/** + * Get Article + */ +export const getArticle = ( + options: Options +): RequestResult => + (options.client ?? client).get({ + url: '/api/analyses/{analysis_id}/articles/{article_id}', + ...options + }); + +/** + * Chat + */ +export const chat = ( + options: Options +): RequestResult => + (options.client ?? client).post({ + url: '/api/analyses/{analysis_id}/chat', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + +/** + * Export Obsidian + */ +export const exportObsidian = ( + options: Options +): RequestResult => + (options.client ?? client).get({ + url: '/api/analyses/{analysis_id}/export', + ...options + }); diff --git a/frontend/src/lib/api/gen/types.gen.ts b/frontend/src/lib/api/gen/types.gen.ts new file mode 100644 index 0000000..4e0d1c0 --- /dev/null +++ b/frontend/src/lib/api/gen/types.gen.ts @@ -0,0 +1,750 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; + +/** + * Analysis + */ +export type Analysis = { + /** + * Id + */ + id: string; + /** + * Topic + */ + topic: string; + /** + * Status + */ + status: + | 'queued' + | 'crawling' + | 'processing' + | 'embedding' + | 'projecting' + | 'done' + | 'error' + | 'cancelled'; + /** + * Progress + */ + progress: number; + /** + * Stage Detail + */ + stage_detail: string | null; + /** + * Article Count + */ + article_count: number; + /** + * Error Msg + */ + error_msg: string | null; + /** + * Warning + */ + warning: string | null; + /** + * Created Ts + */ + created_ts: number; + /** + * Updated Ts + */ + updated_ts: number; +}; + +/** + * AnalysisCreate + */ +export type AnalysisCreate = { + /** + * Topic + */ + topic: string; +}; + +/** + * AnalysisEvent + */ +export type AnalysisEvent = { + /** + * Id + */ + id: number; + /** + * Ts + */ + ts: number; + /** + * Kind + */ + kind: 'stage' | 'query' | 'article' | 'skip' | 'cluster' | 'warning' | 'error'; + /** + * Message + */ + message: string; + /** + * Url + */ + url: string | null; +}; + +/** + * Article + */ +export type Article = { + /** + * Id + */ + id: string; + /** + * Analysis Id + */ + analysis_id: string; + /** + * Url + */ + url: string; + /** + * Domain + */ + domain: string | null; + /** + * Title + */ + title: string | null; + /** + * Publish Date + */ + publish_date: string | null; + /** + * Crawl Ts + */ + crawl_ts: number | null; + /** + * Word Count + */ + word_count: number | null; + /** + * Language + */ + language: string | null; + /** + * Raw Text + */ + raw_text: string | null; + /** + * Status + */ + status: string; +}; + +/** + * ChatRequest + */ +export type ChatRequest = { + /** + * Query + */ + query: string; +}; + +/** + * ChatResponse + */ +export type ChatResponse = { + /** + * Answer + */ + answer: string; + /** + * Citations + */ + citations: Array; +}; + +/** + * Citation + */ +export type Citation = { + /** + * Url + */ + url: string; + /** + * Title + */ + title: string; + /** + * Domain + */ + domain: string; +}; + +/** + * Cluster + */ +export type Cluster = { + /** + * Cluster Id + */ + cluster_id: number; + /** + * Cluster Label + */ + cluster_label: string | null; + /** + * Article Count + */ + article_count: number; +}; + +/** + * CorpusPoint + */ +export type CorpusPoint = { + /** + * Chunk Id + */ + chunk_id: string; + /** + * Article Id + */ + article_id: string; + /** + * Child Text + */ + child_text: string | null; + /** + * Url + */ + url: string; + /** + * Title + */ + title: string | null; + /** + * Domain + */ + domain: string | null; + /** + * Publish Date + */ + publish_date: string | null; + /** + * X + */ + x: number; + /** + * Y + */ + y: number; + /** + * Cluster Id + */ + cluster_id: number; + /** + * Cluster Label + */ + cluster_label: string | null; + /** + * Distance From Centroid + */ + distance_from_centroid: number; + /** + * Word Count + */ + word_count: number | null; +}; + +/** + * HTTPValidationError + */ +export type HttpValidationError = { + /** + * Detail + */ + detail?: Array; +}; + +/** + * Outlier + */ +export type Outlier = { + /** + * Chunk Id + */ + chunk_id: string; + /** + * Cluster Id + */ + cluster_id: number; + /** + * Cluster Label + */ + cluster_label: string | null; + /** + * Distance From Centroid + */ + distance_from_centroid: number; + /** + * Article Id + */ + article_id: string; + /** + * Title + */ + title: string | null; + /** + * Domain + */ + domain: string | null; + /** + * Publish Date + */ + publish_date: string | null; +}; + +/** + * ValidationError + */ +export type ValidationError = { + /** + * Location + */ + loc: Array; + /** + * Message + */ + msg: string; + /** + * Error Type + */ + type: string; + /** + * Input + */ + input?: unknown; + /** + * Context + */ + ctx?: { + [key: string]: unknown; + }; +}; + +export type HealthData = { + body?: never; + path?: never; + query?: never; + url: '/api/health'; +}; + +export type HealthResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ListAnalysesData = { + body?: never; + path?: never; + query?: never; + url: '/api/analyses'; +}; + +export type ListAnalysesResponses = { + /** + * Response List Analyses + * + * Successful Response + */ + 200: Array; +}; + +export type ListAnalysesResponse = ListAnalysesResponses[keyof ListAnalysesResponses]; + +export type CreateAnalysisData = { + body: AnalysisCreate; + path?: never; + query?: never; + url: '/api/analyses'; +}; + +export type CreateAnalysisErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateAnalysisError = CreateAnalysisErrors[keyof CreateAnalysisErrors]; + +export type CreateAnalysisResponses = { + /** + * Successful Response + */ + 201: Analysis; +}; + +export type CreateAnalysisResponse = CreateAnalysisResponses[keyof CreateAnalysisResponses]; + +export type DeleteAnalysisData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}'; +}; + +export type DeleteAnalysisErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteAnalysisError = DeleteAnalysisErrors[keyof DeleteAnalysisErrors]; + +export type DeleteAnalysisResponses = { + /** + * Successful Response + */ + 204: void; +}; + +export type DeleteAnalysisResponse = DeleteAnalysisResponses[keyof DeleteAnalysisResponses]; + +export type GetAnalysisData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}'; +}; + +export type GetAnalysisErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetAnalysisError = GetAnalysisErrors[keyof GetAnalysisErrors]; + +export type GetAnalysisResponses = { + /** + * Successful Response + */ + 200: Analysis; +}; + +export type GetAnalysisResponse = GetAnalysisResponses[keyof GetAnalysisResponses]; + +export type CancelAnalysisData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/cancel'; +}; + +export type CancelAnalysisErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CancelAnalysisError = CancelAnalysisErrors[keyof CancelAnalysisErrors]; + +export type CancelAnalysisResponses = { + /** + * Successful Response + */ + 202: Analysis; +}; + +export type CancelAnalysisResponse = CancelAnalysisResponses[keyof CancelAnalysisResponses]; + +export type GetEventsData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: { + /** + * After + */ + after?: number; + }; + url: '/api/analyses/{analysis_id}/events'; +}; + +export type GetEventsErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetEventsError = GetEventsErrors[keyof GetEventsErrors]; + +export type GetEventsResponses = { + /** + * Response Get Events + * + * Successful Response + */ + 200: Array; +}; + +export type GetEventsResponse = GetEventsResponses[keyof GetEventsResponses]; + +export type StreamAnalysisData = { + body?: never; + headers?: { + /** + * Last-Event-Id + */ + 'last-event-id'?: number; + }; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/stream'; +}; + +export type StreamAnalysisErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type StreamAnalysisError = StreamAnalysisErrors[keyof StreamAnalysisErrors]; + +export type StreamAnalysisResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetCorpusData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/corpus'; +}; + +export type GetCorpusErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetCorpusError = GetCorpusErrors[keyof GetCorpusErrors]; + +export type GetCorpusResponses = { + /** + * Response Get Corpus + * + * Successful Response + */ + 200: Array; +}; + +export type GetCorpusResponse = GetCorpusResponses[keyof GetCorpusResponses]; + +export type GetClustersData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/clusters'; +}; + +export type GetClustersErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetClustersError = GetClustersErrors[keyof GetClustersErrors]; + +export type GetClustersResponses = { + /** + * Response Get Clusters + * + * Successful Response + */ + 200: Array; +}; + +export type GetClustersResponse = GetClustersResponses[keyof GetClustersResponses]; + +export type GetOutliersData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: { + /** + * Cluster Id + */ + cluster_id?: number | null; + }; + url: '/api/analyses/{analysis_id}/outliers'; +}; + +export type GetOutliersErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetOutliersError = GetOutliersErrors[keyof GetOutliersErrors]; + +export type GetOutliersResponses = { + /** + * Response Get Outliers + * + * Successful Response + */ + 200: Array; +}; + +export type GetOutliersResponse = GetOutliersResponses[keyof GetOutliersResponses]; + +export type GetArticleData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + /** + * Article Id + */ + article_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/articles/{article_id}'; +}; + +export type GetArticleErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetArticleError = GetArticleErrors[keyof GetArticleErrors]; + +export type GetArticleResponses = { + /** + * Successful Response + */ + 200: Article; +}; + +export type GetArticleResponse = GetArticleResponses[keyof GetArticleResponses]; + +export type ChatData = { + body: ChatRequest; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/chat'; +}; + +export type ChatErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ChatError = ChatErrors[keyof ChatErrors]; + +export type ChatResponses = { + /** + * Successful Response + */ + 200: ChatResponse; +}; + +export type ChatResponse2 = ChatResponses[keyof ChatResponses]; + +export type ExportObsidianData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/export'; +}; + +export type ExportObsidianErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ExportObsidianError = ExportObsidianErrors[keyof ExportObsidianErrors]; + +export type ExportObsidianResponses = { + /** + * Successful Response + */ + 200: unknown; +}; diff --git a/frontend/src/lib/api/stream.ts b/frontend/src/lib/api/stream.ts new file mode 100644 index 0000000..acfee6c --- /dev/null +++ b/frontend/src/lib/api/stream.ts @@ -0,0 +1,47 @@ +import type { Analysis, AnalysisEvent } from '#lib/api.ts'; + +export interface StreamHandlers { + onAnalysis: (analysis: Analysis) => void; + onEvent: (event: AnalysisEvent) => void; + onGone?: () => void; +} + +/** + * Follow an analysis over server-sent events. Analysis updates arrive as + * default messages, log rows as `log` events with ids (EventSource resends + * Last-Event-ID on reconnect, so nothing is duplicated or lost). + * The server closes the stream once the analysis stops running; we close the + * EventSource then too, instead of letting it reconnect forever. + * Returns a function that stops listening. + */ +export function followAnalysis(id: string, handlers: StreamHandlers): () => void { + const source = new EventSource(`/api/analyses/${encodeURIComponent(id)}/stream`); + let finished = false; + + source.onmessage = (e) => { + const analysis = JSON.parse(e.data) as Analysis; + handlers.onAnalysis(analysis); + if ( + !['queued', 'crawling', 'processing', 'embedding', 'projecting'].includes(analysis.status) + ) { + finished = true; + source.close(); + } + }; + source.addEventListener('log', (e) => { + handlers.onEvent(JSON.parse((e as MessageEvent).data) as AnalysisEvent); + }); + // Server-sent `event: error` carries a body (the analysis was deleted). + source.addEventListener('error', (e) => { + if ((e as MessageEvent).data) { + finished = true; + source.close(); + handlers.onGone?.(); + } else if (finished) { + source.close(); + } + // Otherwise: a dropped connection — EventSource retries on its own. + }); + + return () => source.close(); +} diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..46bea1a --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +Discourse Lens diff --git a/frontend/src/lib/chat.svelte.ts b/frontend/src/lib/chat.svelte.ts new file mode 100644 index 0000000..8879cdc --- /dev/null +++ b/frontend/src/lib/chat.svelte.ts @@ -0,0 +1,62 @@ +import { api, type Citation } from '#lib/api.ts'; + +export interface ChatMessage { + role: 'user' | 'assistant'; + text: string; + citations?: Citation[]; +} + +interface Conversation { + messages: ChatMessage[]; + pending: boolean; + error: string | null; + lastQuestion: string | null; +} + +/** + * Conversations live at module level, keyed by analysis, so switching views + * (which unmounts the Ask page) doesn't throw away the history. + */ +// The registry itself needn't be reactive (it's only ever added to, and is read +// from inside $derived); each conversation is its own reactive object. +// eslint-disable-next-line svelte/prefer-svelte-reactivity -- intentionally not reactive, see above +const conversations = new Map(); + +export function conversation(analysisId: string): Conversation { + const existing = conversations.get(analysisId); + if (existing) return existing; + const fresh: Conversation = $state({ + messages: [], + pending: false, + error: null, + lastQuestion: null + }); + conversations.set(analysisId, fresh); + return fresh; +} + +export async function ask(analysisId: string, question: string): Promise { + const c = conversation(analysisId); + const q = question.trim(); + if (!q || c.pending) return; + c.lastQuestion = q; + c.error = null; + c.messages.push({ role: 'user', text: q }); + c.pending = true; + try { + const res = await api.chat(analysisId, q); + c.messages.push({ role: 'assistant', text: res.answer, citations: res.citations }); + } catch (e) { + c.error = (e as Error).message; + } finally { + c.pending = false; + } +} + +/** Retry the last question after an error, without repeating it in the history. */ +export async function retry(analysisId: string): Promise { + const c = conversation(analysisId); + if (!c.lastQuestion || c.pending) return; + if (c.messages.at(-1)?.role === 'user') c.messages.pop(); + await ask(analysisId, c.lastQuestion); +} diff --git a/frontend/src/lib/components/ArticleSheet.svelte b/frontend/src/lib/components/ArticleSheet.svelte new file mode 100644 index 0000000..3f23c6e --- /dev/null +++ b/frontend/src/lib/components/ArticleSheet.svelte @@ -0,0 +1,113 @@ + + + !open && ws.openArticleById(null)}> + + + +
+
+ {#if point} +

+ + {point.clusterLabel} +

+ {/if} +
+ + + +
+ +
+ + {article?.title ?? point?.title ?? 'Article'} + + + {#if article} + {article.domain} + {#if formatPublishDate(article.publish_date)}{formatPublishDate(article.publish_date)}{/if} + {#if article.word_count}{article.word_count.toLocaleString()} words{/if} + {/if} + + + {#if error} + + {:else if !article} +
+ {#each [92, 100, 84, 96, 60] as w, i (i)} +
+ {/each} +
+ {:else} +
+ {#each paragraphs.slice(0, PARAGRAPHS) as p, i (i)} +

{p}

+ {/each} +
+ {#if paragraphs.length > PARAGRAPHS} +

+ Showing the opening of {paragraphs.length} paragraphs. +

+ {/if} + + + Open the original + + {/if} +
+
+
+
diff --git a/frontend/src/lib/components/ArticleTable.svelte b/frontend/src/lib/components/ArticleTable.svelte new file mode 100644 index 0000000..33e5e21 --- /dev/null +++ b/frontend/src/lib/components/ArticleTable.svelte @@ -0,0 +1,58 @@ + + +
+ + + + + + + + + + + + {#each rows as a (a.id)} + + + + + + + {/each} + +
Articles in this map, grouped by cluster
ArticleCluster
+ + + + + {a.clusterLabel} + +
+
diff --git a/frontend/src/lib/components/ClusterKey.svelte b/frontend/src/lib/components/ClusterKey.svelte new file mode 100644 index 0000000..e038ed5 --- /dev/null +++ b/frontend/src/lib/components/ClusterKey.svelte @@ -0,0 +1,60 @@ + + +
+
+

Clusters

+ {#if selected != null} + + {/if} +
+

Pick one to focus every view on it.

+ +
    + {#each ws.clusters as c (c.id)} +
  • + +
  • + {/each} +
+ + {#if folded} +

+ Only the {MAX_HUES} largest clusters get their own color. The rest share grey; their labels still + name them on the map. +

+ {/if} +
diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..2833828 --- /dev/null +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,45 @@ + + + + + + + {title} + {description} +
+ + Keep it + + { + open = false; + onconfirm(); + }} + > + {confirmLabel} + +
+
+
+
diff --git a/frontend/src/lib/components/EndedView.svelte b/frontend/src/lib/components/EndedView.svelte new file mode 100644 index 0000000..21d36eb --- /dev/null +++ b/frontend/src/lib/components/EndedView.svelte @@ -0,0 +1,40 @@ + + +
+ {#if analysis.status === 'error'} +

This map didn't finish

+

+ {analysis.error_msg ?? 'The pipeline stopped with an unknown error.'} +

+ {:else} +

This map was cancelled

+

Nothing it collected was kept.

+ {/if} + +
diff --git a/frontend/src/lib/components/ProgressView.svelte b/frontend/src/lib/components/ProgressView.svelte new file mode 100644 index 0000000..86ab224 --- /dev/null +++ b/frontend/src/lib/components/ProgressView.svelte @@ -0,0 +1,153 @@ + + +
+
+

+ {analysis.stage_detail ?? 'Starting'} +

+
+
+
+
+
+
Done
+
{analysis.progress}%
+
+
+
Articles kept
+
{indexed}
+
+
+
Elapsed
+
{formatDuration(elapsed)}
+
+
+

+ Most maps take 3 to 8 minutes. You can leave this page; the map keeps building and shows up in + your list. +

+ +
+ +
+

+ Activity + + {plural(indexed, 'article')} kept, {plural(skipped, 'page')} skipped + +

+
    + {#each newestFirst as e (e.id)} + {@const Icon = ICONS[e.kind]} +
  1. +
  2. + {:else} +
  3. Waiting for the first step…
  4. + {/each} +
+
+
diff --git a/frontend/src/lib/components/StatusLabel.svelte b/frontend/src/lib/components/StatusLabel.svelte new file mode 100644 index 0000000..2a0b45a --- /dev/null +++ b/frontend/src/lib/components/StatusLabel.svelte @@ -0,0 +1,27 @@ + + + + {#if analysis.status === 'done'} + diff --git a/frontend/src/lib/components/SurveyMap.svelte b/frontend/src/lib/components/SurveyMap.svelte new file mode 100644 index 0000000..68151bc --- /dev/null +++ b/frontend/src/lib/components/SurveyMap.svelte @@ -0,0 +1,266 @@ + + +
+ {#if width && height} + + + ws.selectCluster(null)} + role="presentation" + /> + + + {#each contours as c, i (i)} + + {/each} + + + {#each marks as m (m.a.id)} + (hover = { a: m.a, x: m.sx, y: m.sy })} + onmouseleave={() => (hover = null)} + onclick={(e) => { + e.stopPropagation(); + ws.openArticleById(m.a.id); + }} + role="presentation" + > + + + + {/each} + + {#each labels as l (l.id)} + { + e.stopPropagation(); + ws.selectCluster(l.id); + }} + role="presentation" + > + + + {l.text} + + + {/each} + + + {#if hover} +
+

{hover.a.title}

+

+ {hover.a.domain} + {#if formatPublishDate(hover.a.date)}{formatPublishDate(hover.a.date)}{/if} +

+

+ + {hover.a.clusterLabel} +

+
+ {/if} + +
+ + + +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/ThemeToggle.svelte b/frontend/src/lib/components/ThemeToggle.svelte new file mode 100644 index 0000000..af0d127 --- /dev/null +++ b/frontend/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/corpus.spec.ts b/frontend/src/lib/corpus.spec.ts new file mode 100644 index 0000000..b65489d --- /dev/null +++ b/frontend/src/lib/corpus.spec.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import type { CorpusPoint } from '#lib/api.ts'; +import { + aggregateArticles, + buildTimeline, + distinctiveTerms, + MAX_HUES, + parseYearMonth, + summarizeClusters, + tokenize, + type ArticlePoint +} from './corpus'; + +function point(over: Partial): CorpusPoint { + return { + chunk_id: 'c', + article_id: 'a', + child_text: '', + url: 'https://x.test/a', + title: 'A', + domain: 'x.test', + publish_date: null, + x: 0, + y: 0, + cluster_id: 0, + cluster_label: 'Zero', + distance_from_centroid: 0, + word_count: 500, + ...over + }; +} + +function article(over: Partial): ArticlePoint { + return { + id: 'a', + title: 'A', + url: '', + domain: '', + date: null, + wordCount: 0, + clusterId: 0, + clusterLabel: 'Zero', + x: 0, + y: 0, + chunks: 1, + distance: 0, + ...over + }; +} + +describe('aggregateArticles', () => { + it('uses the majority cluster and averages only its chunks', () => { + const [a] = aggregateArticles([ + point({ + chunk_id: '1', + cluster_id: 1, + cluster_label: 'One', + x: 2, + y: 2, + distance_from_centroid: 0.4 + }), + point({ + chunk_id: '2', + cluster_id: 1, + cluster_label: 'One', + x: 4, + y: 6, + distance_from_centroid: 0.7 + }), + point({ chunk_id: '3', cluster_id: 0, x: 100, y: 100, distance_from_centroid: 9 }) + ]); + expect(a.clusterId).toBe(1); + expect(a.clusterLabel).toBe('One'); + expect([a.x, a.y]).toEqual([3, 4]); + expect(a.chunks).toBe(3); + // outlier score only counts chunks in the article's own cluster + expect(a.distance).toBe(0.7); + }); + + it('breaks ties toward the lower cluster id', () => { + const [a] = aggregateArticles([ + point({ chunk_id: '1', cluster_id: 2 }), + point({ chunk_id: '2', cluster_id: 1 }) + ]); + expect(a.clusterId).toBe(1); + }); +}); + +describe('summarizeClusters', () => { + it('gives hues to the largest clusters and folds the rest', () => { + const articles: ArticlePoint[] = []; + for (let c = 0; c < MAX_HUES + 2; c++) { + for (let i = 0; i <= c; i++) articles.push(article({ id: `${c}-${i}`, clusterId: c })); + } + const clusters = summarizeClusters(articles); + expect(clusters[0]).toMatchObject({ id: MAX_HUES + 1, slot: 1 }); + expect(clusters.filter((c) => c.slot === null).map((c) => c.id)).toEqual([1, 0]); + }); +}); + +describe('parseYearMonth', () => { + it('accepts day and month precision', () => { + expect(parseYearMonth('2024-03-15')).toEqual([2024, 2]); + expect(parseYearMonth('2024-03')).toEqual([2024, 2]); + }); + it('rejects missing or malformed dates', () => { + expect(parseYearMonth(null)).toBeNull(); + expect(parseYearMonth('2024-13')).toBeNull(); + expect(parseYearMonth('March 2024')).toBeNull(); + }); +}); + +describe('buildTimeline', () => { + it('bins by month and fills gaps with zeros', () => { + const t = buildTimeline( + [ + article({ id: '1', date: '2024-01-05', clusterId: 0 }), + article({ id: '2', date: '2024-03-01', clusterId: 1 }), + article({ id: '3', date: null }) + ], + [0, 1] + ); + expect(t.unit).toBe('month'); + expect(t.bins.map((b) => b.key)).toEqual(['2024-01', '2024-02', '2024-03']); + expect(t.bins[1].total).toBe(0); + expect(t.bins[2].counts).toEqual({ 0: 0, 1: 1 }); + expect([t.dated, t.undated]).toEqual([2, 1]); + }); + + it('switches to years for long spans', () => { + const t = buildTimeline( + [article({ id: '1', date: '2010-06' }), article({ id: '2', date: '2024-02' })], + [0] + ); + expect(t.unit).toBe('year'); + expect(t.bins[0].key).toBe('2010'); + expect(t.bins.at(-1)!.key).toBe('2024'); + expect(t.bins).toHaveLength(15); + }); + + it('handles a corpus with no dates', () => { + expect(buildTimeline([article({})], [0])).toMatchObject({ bins: [], dated: 0, undated: 1 }); + }); +}); + +describe('distinctiveTerms', () => { + it('ranks words specific to a cluster above shared ones', () => { + const pts: CorpusPoint[] = []; + for (let i = 0; i < 4; i++) { + pts.push(point({ cluster_id: 0, child_text: 'rebound effect energy demand' })); + pts.push(point({ cluster_id: 1, child_text: 'carbon pricing energy policy' })); + } + const terms = distinctiveTerms(pts); + const zero = terms.get(0)!.map((t) => t.term); + expect(zero).toContain('rebound'); + expect(zero).not.toContain('energy'); // in every chunk → idf 0 + expect(terms.get(0)![0].share).toBe(1); + }); +}); + +describe('tokenize', () => { + it('ignores URLs', () => { + expect(tokenize('see https://example.com/heat-study and www.city.gov/plan today')).toEqual([ + 'today' + ]); + }); + + it('drops stop words, short words, numbers and possessives', () => { + expect(tokenize("Jevons's paradox: efficiency would raise 2024 demand")).toEqual([ + 'jevons', + 'paradox', + 'efficiency', + 'raise', + 'demand' + ]); + }); +}); diff --git a/frontend/src/lib/corpus.ts b/frontend/src/lib/corpus.ts new file mode 100644 index 0000000..e4e4ff1 --- /dev/null +++ b/frontend/src/lib/corpus.ts @@ -0,0 +1,238 @@ +/** + * Turning the API's chunk-level corpus into article-level views. + * Pure functions — no DOM, no Svelte — so they're unit-tested directly. + */ +import type { CorpusPoint } from '#lib/api.ts'; + +export interface ArticlePoint { + id: string; + title: string; + url: string; + domain: string; + date: string | null; // YYYY-MM or YYYY-MM-DD + wordCount: number; + clusterId: number; + clusterLabel: string; + x: number; + y: number; + chunks: number; + /** Furthest any of its chunks sits from its cluster's centre (outlier score) */ + distance: number; +} + +/** + * One point per article. Its cluster is the one most of its chunks fall in, + * and its position is the mean of those chunks only — averaging every chunk + * can drop an article into empty space between clusters. + */ +export function aggregateArticles(points: CorpusPoint[]): ArticlePoint[] { + const byArticle = new Map(); + for (const p of points) { + const list = byArticle.get(p.article_id); + if (list) list.push(p); + else byArticle.set(p.article_id, [p]); + } + + const articles: ArticlePoint[] = []; + for (const [id, chunks] of byArticle) { + const counts = new Map(); + for (const c of chunks) counts.set(c.cluster_id, (counts.get(c.cluster_id) ?? 0) + 1); + // Majority cluster; ties go to the lower cluster id for stability. + const clusterId = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0][0]; + const inCluster = chunks.filter((c) => c.cluster_id === clusterId); + const first = inCluster[0]; + articles.push({ + id, + title: first.title || first.domain || 'Untitled', + url: first.url, + domain: first.domain ?? '', + date: first.publish_date, + wordCount: first.word_count ?? 0, + clusterId, + clusterLabel: first.cluster_label || `Cluster ${clusterId + 1}`, + x: mean(inCluster.map((c) => c.x)), + y: mean(inCluster.map((c) => c.y)), + chunks: chunks.length, + distance: Math.max(...inCluster.map((c) => c.distance_from_centroid)) + }); + } + return articles; +} + +export interface ClusterInfo { + id: number; + label: string; + articles: number; + /** 1-8: a categorical hue slot; null: folded into the neutral "other" ink */ + slot: number | null; +} + +export const MAX_HUES = 8; + +/** + * Clusters ordered by size. The largest MAX_HUES take the categorical slots in + * fixed order; any beyond that share the neutral ink — a ninth hue would not be + * distinguishable. Labels still name every cluster on the map and in the key. + */ +export function summarizeClusters(articles: ArticlePoint[]): ClusterInfo[] { + const map = new Map(); + for (const a of articles) { + const c = map.get(a.clusterId); + if (c) c.articles++; + else map.set(a.clusterId, { id: a.clusterId, label: a.clusterLabel, articles: 1, slot: null }); + } + const ordered = [...map.values()].sort((a, b) => b.articles - a.articles || a.id - b.id); + ordered.forEach((c, i) => (c.slot = i < MAX_HUES ? i + 1 : null)); + return ordered; +} + +export function clusterColor(slot: number | null): string { + return slot == null ? 'var(--series-other)' : `var(--series-${slot})`; +} + +// ── Timeline ──────────────────────────────────────────────────────────────── + +export interface TimelineBin { + key: string; // YYYY or YYYY-MM + start: Date; + counts: Record; + total: number; +} + +export interface Timeline { + unit: 'month' | 'year'; + bins: TimelineBin[]; + dated: number; + undated: number; +} + +/** Spans longer than this many months are binned by year instead. */ +export const MONTHLY_SPAN_LIMIT = 36; + +export function buildTimeline(articles: ArticlePoint[], clusterIds: number[]): Timeline { + const dated = articles + .map((a) => ({ a, ym: parseYearMonth(a.date) })) + .filter((d): d is { a: ArticlePoint; ym: [number, number] } => d.ym !== null); + const undated = articles.length - dated.length; + if (dated.length === 0) return { unit: 'month', bins: [], dated: 0, undated }; + + const index = (ym: [number, number]) => ym[0] * 12 + ym[1]; + const lo = Math.min(...dated.map((d) => index(d.ym))); + const hi = Math.max(...dated.map((d) => index(d.ym))); + const unit = hi - lo + 1 > MONTHLY_SPAN_LIMIT ? 'year' : 'month'; + + const keyOf = (ym: [number, number]) => + unit === 'year' ? String(ym[0]) : `${ym[0]}-${String(ym[1] + 1).padStart(2, '0')}`; + + // Every bin in the range, so gaps show as zero rather than vanishing. + const bins = new Map(); + const step = unit === 'year' ? 12 : 1; + const first = unit === 'year' ? Math.floor(lo / 12) * 12 : lo; + for (let i = first; i <= hi; i += step) { + const ym: [number, number] = [Math.floor(i / 12), i % 12]; + const counts: Record = {}; + for (const id of clusterIds) counts[id] = 0; + bins.set(keyOf(ym), { + key: keyOf(ym), + start: new Date(Date.UTC(ym[0], unit === 'year' ? 0 : ym[1], 1)), + counts, + total: 0 + }); + } + for (const { a, ym } of dated) { + const bin = bins.get(keyOf(ym))!; + bin.counts[a.clusterId] = (bin.counts[a.clusterId] ?? 0) + 1; + bin.total++; + } + return { unit, bins: [...bins.values()], dated: dated.length, undated }; +} + +/** "2024-03-15" | "2024-03" → [2024, 2] (zero-based month); anything else → null */ +export function parseYearMonth(date: string | null | undefined): [number, number] | null { + const m = date?.match(/^(\d{4})-(\d{2})/); + if (!m) return null; + const year = Number(m[1]); + const month = Number(m[2]) - 1; + if (month < 0 || month > 11 || year < 1900) return null; + return [year, month]; +} + +// ── Key terms ─────────────────────────────────────────────────────────────── + +export interface Term { + term: string; + score: number; + /** score relative to the cluster's top term, 0-1 */ + share: number; +} + +/** + * Words that set each cluster apart: TF-IDF over chunks, where a term's + * frequency inside the cluster is weighed against how many chunks across the + * whole corpus use it. Terms in fewer than MIN_DF chunks are dropped as noise. + */ +export function distinctiveTerms(points: CorpusPoint[], perCluster = 8): Map { + const MIN_DF = 3; + const docs = points + .filter((p) => p.child_text) + .map((p) => ({ cluster: p.cluster_id, terms: tokenize(p.child_text!) })); + const df = new Map(); + for (const d of docs) for (const t of new Set(d.terms)) df.set(t, (df.get(t) ?? 0) + 1); + + const byCluster = new Map; n: number }>(); + for (const d of docs) { + let c = byCluster.get(d.cluster); + if (!c) byCluster.set(d.cluster, (c = { tf: new Map(), n: 0 })); + c.n++; + for (const t of d.terms) c.tf.set(t, (c.tf.get(t) ?? 0) + 1); + } + + const out = new Map(); + for (const [cluster, { tf, n }] of byCluster) { + const scored = [...tf.entries()] + .filter(([t]) => (df.get(t) ?? 0) >= MIN_DF) + .map(([term, count]) => ({ + term, + score: (count / n) * Math.log(docs.length / (df.get(term) ?? 1)) + })) + .filter((t) => t.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, perCluster); + const top = scored[0]?.score || 1; + out.set( + cluster, + scored.map((t) => ({ ...t, share: t.score / top })) + ); + } + return out; +} + +export function tokenize(text: string): string[] { + return text + .toLowerCase() + .replace(/\b(?:https?:\/\/|www\.)\S+/g, ' ') // URLs aren't vocabulary + .replace(/[^a-z0-9\s'-]/g, ' ') + .split(/\s+/) + .map((w) => w.replace(/^['-]+|['-]+$/g, '').replace(/'s$/, '')) + .filter((w) => w.length > 3 && !/^\d+$/.test(w) && !STOP_WORDS.has(w)); +} + +const STOP_WORDS = new Set( + `about above after again against also although among another anyone anything around because been + before being below between both cannot could does doing down during each either else even ever + every first from further given have having here hers herself himself however into itself just + last less like made make makes making many might more most much must myself near need neither + never next none often once only other others ought ours ourselves over own perhaps quite rather + really same several shall should since some something still such than that their theirs them + themselves then there therefore these they thing things this those though through thus together + took toward towards under until upon very want were what whatever when where whether which while + whom whose will with within without would year years your yours yourself yourselves said says + according also including include includes used using well across within three four five + example however people time times way ways part help however based new however` + .split(/\s+/) + .filter(Boolean) +); + +function mean(xs: number[]): number { + return xs.reduce((s, x) => s + x, 0) / xs.length; +} diff --git a/frontend/src/lib/d3helpers.js b/frontend/src/lib/d3helpers.js deleted file mode 100644 index da38d2f..0000000 --- a/frontend/src/lib/d3helpers.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as d3 from 'd3'; - -// Distinct palette tuned for dark backgrounds -const PALETTE = [ - '#5a7fff', '#ff6b6b', '#ffd93d', '#6bcb77', '#ff9671', - '#a78bfa', '#38bdf8', '#fb923c', '#34d399', '#f472b6', -]; - -export function getClusterColor(clusterId) { - return PALETTE[((clusterId ?? 0) % PALETTE.length + PALETTE.length) % PALETTE.length]; -} - -export function getNodeRadius(wordCount) { - return d3.scaleSqrt().domain([0, 5000]).range([3, 11]).clamp(true)(wordCount || 500); -} - -export function formatMonth(dateStr) { - if (!dateStr) return null; - try { - const d = new Date((dateStr.length <= 7 ? dateStr + '-01' : dateStr)); - if (isNaN(d)) return null; - return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); - } catch { return null; } -} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..2fced14 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,38 @@ +const dateFmt = new Intl.DateTimeFormat(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric' +}); +const monthFmt = new Intl.DateTimeFormat(undefined, { + month: 'short', + year: 'numeric', + timeZone: 'UTC' +}); +const yearFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', timeZone: 'UTC' }); + +/** Unix seconds → "11 Oct 2026" */ +export function formatTimestamp(ts: number): string { + return dateFmt.format(new Date(ts * 1000)); +} + +/** "2024-03-15" / "2024-03" → "Mar 2024"; null → null */ +export function formatPublishDate(date: string | null | undefined): string | null { + const m = date?.match(/^(\d{4})-(\d{2})/); + if (!m) return null; + return monthFmt.format(new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, 1))); +} + +export function formatBin(start: Date, unit: 'month' | 'year'): string { + return (unit === 'year' ? yearFmt : monthFmt).format(start); +} + +export function plural(n: number, one: string, many = `${one}s`): string { + return `${n.toLocaleString()} ${n === 1 ? one : many}`; +} + +/** Seconds → "3 min 20 s" / "45 s" */ +export function formatDuration(seconds: number): string { + const s = Math.max(0, Math.round(seconds)); + if (s < 60) return `${s} s`; + return `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`; +} diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..73c55ef --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `#lib` alias in this folder. diff --git a/frontend/src/lib/presets.js b/frontend/src/lib/presets.js deleted file mode 100644 index 66ee356..0000000 --- a/frontend/src/lib/presets.js +++ /dev/null @@ -1,5 +0,0 @@ -export const PRESET_QUERIES = [ - 'What are the main camps or schools of thought in this discourse?', - 'What topics or angles appear underexplored in the current coverage?', - 'How has the conversation shifted or evolved over time?', -]; diff --git a/frontend/src/lib/workspace.svelte.ts b/frontend/src/lib/workspace.svelte.ts new file mode 100644 index 0000000..cb5002a --- /dev/null +++ b/frontend/src/lib/workspace.svelte.ts @@ -0,0 +1,104 @@ +import { getContext, setContext } from 'svelte'; +import { goto } from '$app/navigation'; +import { resolve } from '$app/paths'; +import { page } from '$app/state'; +import type { Analysis, CorpusPoint } from '#lib/api.ts'; +import { + aggregateArticles, + clusterColor, + summarizeClusters, + type ArticlePoint, + type ClusterInfo +} from '#lib/corpus.ts'; + +const VIEW_ROUTES = { + map: '/analysis/[id]', + timeline: '/analysis/[id]/timeline', + outliers: '/analysis/[id]/outliers', + terms: '/analysis/[id]/terms', + ask: '/analysis/[id]/ask' +} as const; + +export type View = keyof typeof VIEW_ROUTES; + +/** + * Everything the result views share for one analysis: article-level points, + * clusters with their colors, and the selection — which lives in the URL + * (?cluster=, ?article=) so any view can be linked to and survives reloads. + */ +export class Workspace { + readonly articles: ArticlePoint[]; + readonly clusters: ClusterInfo[]; + readonly #byId: Map; + readonly #articleByUrl: Map; + + constructor( + readonly analysis: Analysis, + readonly points: CorpusPoint[] + ) { + this.articles = aggregateArticles(points); + this.clusters = summarizeClusters(this.articles); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- built once per Workspace, never mutated + this.#byId = new Map(this.clusters.map((c) => [c.id, c])); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- built once per Workspace, never mutated + this.#articleByUrl = new Map(this.articles.map((a) => [a.url, a])); + } + + cluster(id: number): ClusterInfo | undefined { + return this.#byId.get(id); + } + + color(clusterId: number): string { + return clusterColor(this.#byId.get(clusterId)?.slot ?? null); + } + + articleByUrl(url: string): ArticlePoint | undefined { + return this.#articleByUrl.get(url); + } + + get selectedCluster(): number | null { + const v = page.url.searchParams.get('cluster'); + const id = v == null ? NaN : Number(v); + return Number.isInteger(id) && this.#byId.has(id) ? id : null; + } + + get openArticle(): string | null { + return page.url.searchParams.get('article'); + } + + selectCluster(id: number | null) { + this.#setParam('cluster', id == null || id === this.selectedCluster ? null : String(id)); + } + + openArticleById(id: string | null) { + this.#setParam('article', id); + } + + /** href for another view of this analysis, carrying the cluster selection */ + viewHref(view: View): string { + const href = resolve(VIEW_ROUTES[view], { id: this.analysis.id }); + const c = this.selectedCluster; + return c == null ? href : `${href}?cluster=${c}`; + } + + #setParam(key: string, value: string | null) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- throwaway, only used to build the next URL + const url = new URL(page.url.href); + if (value == null) url.searchParams.delete(key); + else url.searchParams.set(key, value); + // Selection is view state: replace the history entry, keep scroll and focus. + // Same page, new query: page.url's path already includes any base path. + // eslint-disable-next-line svelte/no-navigation-without-resolve + goto(url, { replace: true, reset: false }); + } +} + +const KEY = Symbol('workspace'); + +export function setWorkspace(get: () => Workspace) { + setContext(KEY, get); +} + +export function useWorkspace(): Workspace { + return (getContext(KEY) as () => Workspace)(); +} diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..d4b5533 --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,12 @@ + + +
+

{page.status === 404 ? 'Not found' : 'Something went wrong'}

+

{page.error?.message ?? 'An unexpected error occurred.'}

+ Back to all maps +
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..2e9bc2c --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,19 @@ + + + + + Discourse Lens + + + + + +{@render children()} diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..77bbfd0 --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,3 @@ +// Single-page app served by FastAPI: no server rendering, no prerendering. +export const ssr = false; +export const prerender = false; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index e6ac343..396a3d5 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,766 +1,176 @@ - - -{#if phase === 'input'} -
- -
-

Discourse Lens

-

Map the shape of any conversation in under 10 minutes.

-
-
-
- - -
-
-
- - -{:else if phase === 'loading'} -
- -
-
{currentTopic}
- {#if jobStatus === 'error'} -
{jobError || 'Pipeline failed.'}
- - {:else} -
{STATUS_LABELS[jobStatus] || jobStatus}
-
-
-
-
{jobProgress}%
- {/if} -
-
- - -{:else if phase === 'results'} -
- - {#if jobWarning} -
{jobWarning}
- {/if} - - -
- {currentTopic} - {points.length} points · {clusterCount} clusters -
- {#if exportError}{exportError}{/if} - - - -
-
- - - - - -
- - -
-
- -
- -
-
- -
-
- -
- -
- -
- -
- -
- -
-
- - - -
- - - -
-{/if} - - +
+
+ + + Discourse Lens + + +
+ +
+
+

Map a conversation.

+

+ Enter a topic. Discourse Lens reads around a hundred articles about it and charts the camps, + the outliers and how the conversation has moved. A map takes 3 to 8 minutes. +

+ +
+ + + +
+ {#if formError} + + {/if} + +
+ Try + {#each SUGGESTIONS as s (s)} + + {/each} +
+
+ +
+

Your maps

+ {#if data.analyses.length === 0} +

+ No maps yet. The first one shows up here as soon as it starts. +

+ {:else} +
    + {#each data.analyses as a (a.id)} +
  • + + {a.topic} + + + + +
  • + {/each} +
+ {/if} +
+
+
+ + diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts new file mode 100644 index 0000000..d33ab1e --- /dev/null +++ b/frontend/src/routes/+page.ts @@ -0,0 +1,7 @@ +import { api } from '#lib/api.ts'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ fetch, depends }) => { + depends('app:analyses'); + return { analyses: await api.listAnalyses(fetch) }; +}; diff --git a/frontend/src/routes/analysis/[id]/+layout.svelte b/frontend/src/routes/analysis/[id]/+layout.svelte new file mode 100644 index 0000000..61f72c2 --- /dev/null +++ b/frontend/src/routes/analysis/[id]/+layout.svelte @@ -0,0 +1,124 @@ + + + + {data.analysis.topic} · Discourse Lens + + +
+
+ + +
+ {#if data.analysis.status === 'done'} + + + + + {/if} + +
+
+ +
+

+ {data.analysis.topic} +

+ {#if data.analysis.status === 'done'} +

+ {plural(ws.articles.length, 'article')} in {plural(ws.clusters.length, 'cluster')}, mapped + {formatTimestamp(data.analysis.updated_ts)} +

+ {/if} +
+ + {#if isRunning(data.analysis)} + + {:else if data.analysis.status !== 'done'} + + {:else} + {#if data.analysis.warning} +

+

+ {/if} + + + +
+
+ {@render children()} +
+ +
+ + + {/if} +
diff --git a/frontend/src/routes/analysis/[id]/+layout.ts b/frontend/src/routes/analysis/[id]/+layout.ts new file mode 100644 index 0000000..84653c1 --- /dev/null +++ b/frontend/src/routes/analysis/[id]/+layout.ts @@ -0,0 +1,18 @@ +import { error } from '@sveltejs/kit'; +import { api, ApiError } from '#lib/api.ts'; +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = async ({ params, fetch, depends }) => { + depends('app:analysis'); + let analysis; + try { + analysis = await api.getAnalysis(params.id, fetch); + } catch (e) { + if (e instanceof ApiError && e.status === 404) { + error(404, 'There is no map with this address. It may have been deleted.'); + } + throw e; + } + const points = analysis.status === 'done' ? await api.getCorpus(params.id, fetch) : []; + return { analysis, points }; +}; diff --git a/frontend/src/routes/analysis/[id]/+page.svelte b/frontend/src/routes/analysis/[id]/+page.svelte new file mode 100644 index 0000000..dc63b5e --- /dev/null +++ b/frontend/src/routes/analysis/[id]/+page.svelte @@ -0,0 +1,34 @@ + + +
+
+ +
+ {#each [['map', 'Map'], ['table', 'Table']] as const as [v, label] (v)} + + {/each} +
+
+
+ {#if view === 'map'}{:else}{/if} +
+
diff --git a/frontend/src/routes/analysis/[id]/ask/+page.svelte b/frontend/src/routes/analysis/[id]/ask/+page.svelte new file mode 100644 index 0000000..39b7a83 --- /dev/null +++ b/frontend/src/routes/analysis/[id]/ask/+page.svelte @@ -0,0 +1,187 @@ + + +
+
+
+ {#if chat.messages.length === 0} +

Ask the corpus

+

+ Answers come only from the {ws.articles.length} articles in this map, with the sources they + drew on listed underneath. +

+
    + {#each STARTERS as s (s)} +
  • + +
  • + {/each} +
+ {/if} + +
+ {#each chat.messages as m, i (i)} + {#if m.role === 'user'} +

{m.text}

+ {:else} +
+ +
{@html render(m.text)}
+ {#if m.citations?.length} +

Sources

+
    + {#each m.citations as c, j (c.url)} + {@const local = ws.articleByUrl(c.url)} +
  1. + {j + 1}. + {#if local} + + {:else} + + + {c.title} + + + {/if} + {c.domain} +
  2. + {/each} +
+ {/if} +
+ {/if} + {/each} + + {#if chat.pending} +

+ + Reading the corpus… +

+ {/if} + {#if chat.error} + + {/if} +
+
+
+ +
{ + e.preventDefault(); + if (question.trim()) send(question); + }} + > +
+ + + +
+
+
+ + diff --git a/frontend/src/routes/analysis/[id]/outliers/+page.svelte b/frontend/src/routes/analysis/[id]/outliers/+page.svelte new file mode 100644 index 0000000..46c7fb4 --- /dev/null +++ b/frontend/src/routes/analysis/[id]/outliers/+page.svelte @@ -0,0 +1,59 @@ + + +
+

+ Articles furthest from the middle of their cluster{ws.selectedCluster != null + ? ` (${ws.cluster(ws.selectedCluster)?.label})` + : ''}. They often argue something the rest of their camp doesn't, or don't fit any camp well. +

+ +
    + {#each rows as r (r.id)} +
  1. + +
  2. + {:else} +
  3. No articles in this cluster.
  4. + {/each} +
+
diff --git a/frontend/src/routes/analysis/[id]/terms/+page.svelte b/frontend/src/routes/analysis/[id]/terms/+page.svelte new file mode 100644 index 0000000..2a75b1d --- /dev/null +++ b/frontend/src/routes/analysis/[id]/terms/+page.svelte @@ -0,0 +1,67 @@ + + +
+

+ Words each cluster uses far more than the rest of the corpus does. Bars are relative to the + cluster's strongest word. +

+
+ {#each ordered as c (c.id)} +
+

+ +

+
    + {#each terms.get(c.id) ?? [] as t (t.term)} +
  • + {t.term} + + + +
  • + {:else} +
  • Too little text to find distinctive words.
  • + {/each} +
+
+ {/each} +
+
diff --git a/frontend/src/routes/analysis/[id]/timeline/+page.svelte b/frontend/src/routes/analysis/[id]/timeline/+page.svelte new file mode 100644 index 0000000..fadca9c --- /dev/null +++ b/frontend/src/routes/analysis/[id]/timeline/+page.svelte @@ -0,0 +1,149 @@ + + +
+

+ {#if timeline.dated === 0} + None of these articles carry a publish date, so there's no timeline to draw. + {:else} + Articles per {timeline.unit} by cluster. {plural(timeline.dated, 'article')} have a publish date{timeline.undated + ? `; ${timeline.undated} don't and are left out` + : ''}. + {/if} +

+ + {#if timeline.dated > 0} +
+ {#if width && height} + + {#each yTicks as t (t)} + + {t} + {/each} + + + {#each segments as s (s.bin.key + ':' + s.cluster)} + + + {/each} + + {#each timeline.bins as b, i (b.key)} + {#if i % tickEvery === 0} + + {formatBin(b.start, timeline.unit)} + + {/if} + + (hover = b)} + onmouseleave={() => (hover = null)} + role="presentation" + /> + {/each} + + + {#if hover && hover.total > 0} + {@const left = x(hover.key)! + x.bandwidth() + 10} +
+

{formatBin(hover.start, timeline.unit)}

+
    + {#each ws.clusters.filter((c) => hover!.counts[c.id]) as c (c.id)} +
  • + + {c.label} + {hover.counts[c.id]} +
  • + {/each} +
+
+ {/if} + {/if} +
+ {/if} +
+ + diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css new file mode 100644 index 0000000..771fea3 --- /dev/null +++ b/frontend/src/routes/layout.css @@ -0,0 +1,116 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/typography'; +@import '@fontsource-variable/mona-sans/wdth.css'; + +/* mode-watcher toggles a `dark` class on */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* + * Survey-chart palette. Cool chart paper and slate-teal ink; one accent + * (survey-marker magenta) for the primary action and focus. Cluster hues are + * the only other saturated colors — see #lib/palette. + */ +:root { + color-scheme: light; + --paper: #f3f6f7; + --raised: #fbfcfc; + --ink: #1b2a33; + --ink-2: #3e4f59; + --muted: #5b6b74; + --rule: #c9d4d9; + --rule-soft: #dde5e8; + --accent: #b0306a; + --accent-ink: #ffffff; + --danger: #b42318; + + /* Categorical cluster hues, light steps (validated on --paper) */ + --series-1: #2a78d6; + --series-2: #eb6834; + --series-3: #1baf7a; + --series-4: #eda100; + --series-5: #e87ba4; + --series-6: #008300; + --series-7: #4a3aa7; + --series-8: #e34948; + --series-other: #8b9aa2; +} + +:root.dark { + color-scheme: dark; + --paper: #121d25; + --raised: #172530; + --ink: #dce6ea; + --ink-2: #b5c3c9; + --muted: #8a9ca5; + --rule: #2a3b45; + --rule-soft: #1f2e38; + --accent: #e0609a; + --accent-ink: #121d25; + --danger: #f0776b; + + /* Same hues stepped for the night surface (validated on --paper) */ + --series-1: #3987e5; + --series-2: #d95926; + --series-3: #199e70; + --series-4: #c98500; + --series-5: #d55181; + --series-6: #008300; + --series-7: #9085e9; + --series-8: #e66767; + --series-other: #5e707a; +} + +@theme inline { + --color-paper: var(--paper); + --color-raised: var(--raised); + --color-ink: var(--ink); + --color-ink-2: var(--ink-2); + --color-muted: var(--muted); + --color-rule: var(--rule); + --color-rule-soft: var(--rule-soft); + --color-accent: var(--accent); + --color-accent-ink: var(--accent-ink); + --color-danger: var(--danger); + --font-sans: 'Mona Sans Variable', ui-sans-serif, system-ui, sans-serif; +} + +@layer base { + html { + background: var(--paper); + color: var(--ink); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + } + body { + min-height: 100dvh; + } + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + ::selection { + background: color-mix(in oklab, var(--accent) 25%, transparent); + } +} + +@utility tabular { + font-variant-numeric: tabular-nums; +} + +/* Expanded width is the display voice: titles and map labels. */ +@utility display { + font-stretch: 125%; + font-weight: 600; + letter-spacing: -0.01em; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js deleted file mode 100644 index b42b17b..0000000 --- a/frontend/svelte.config.js +++ /dev/null @@ -1,10 +0,0 @@ -import adapter from '@sveltejs/adapter-auto'; - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - kit: { - adapter: adapter() - } -}; - -export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..825bd75 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "$app/tsconfig", + "compilerOptions": { + "strict": true, + "types": ["$app/types", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js deleted file mode 100644 index 3406f32..0000000 --- a/frontend/vite.config.js +++ /dev/null @@ -1,6 +0,0 @@ -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [sveltekit()] -}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5d41fde --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,40 @@ +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vitest/config'; +import adapter from '@sveltejs/adapter-static'; +import { sveltekit } from '@sveltejs/kit/vite'; + +// The backend serves the built app and the API from one origin, so the client +// always uses relative /api URLs. In dev, Vite forwards them to uvicorn. +const API_ORIGIN = process.env.API_ORIGIN ?? 'http://localhost:8001'; + +export default defineConfig({ + plugins: [ + tailwindcss(), + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => + filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + // Single-page app: every unknown path falls back to index.html. + adapter: adapter({ fallback: 'index.html' }) + }) + ], + server: { + proxy: { '/api': API_ORIGIN } + }, + test: { + expect: { requireAssertions: true }, + projects: [ + { + extends: './vite.config.ts', + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + } + ] + } +}); From 01d70224384091633113dc4dda0b73d89823d189 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Mon, 12 Oct 2026 18:36:35 -0400 Subject: [PATCH 07/16] Replace k-means with an article-level topic model Chunks of one article are near-identical in embedding space, so clustering chunks grouped text by source rather than by argument (HDBSCAN found ~one "topic" per article). Topics are now found among articles. - backend/topics.py: BERTopic's recipe without its PyTorch dependency. Article vectors -> UMAP 5D -> HDBSCAN (leaf selection) -> merge splits members can't tell apart -> noise joins a topic only if it would pass as a member, else stays unclustered (-1) -> cap at 8 topics (the palette) -> c-TF-IDF key terms (needing 2+ articles per term; boilerplate, citation and month words filtered). Separate looser 2D UMAP for the map, tuned on real corpora for ~2.5x more room per topic - One Claude call names all topics with structured output, so names contrast (the per-topic calls produced duplicates such as two "Urban Heat Mitigation"); falls back to key terms if the call fails - Schema v4: placements (one row per article: x, y, topic, outlier distance) and clusters (label, description, size, terms) replace chunk-level projections; v3 data converted on startup - API: GET /analyses/{id}/articles and /clusters (with terms and descriptions); /corpus and /outliers removed - Whole-article near-duplicate detection (MinHash on word shingles) catches syndicated copies whose intros differ; marked 'duplicate' - scripts/recluster.py rebuilds maps from stored embeddings, no re-crawl - complete() accepts a JSON schema (structured outputs) Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++- backend/api/main.py | 159 +++++++------------- backend/claude_client.py | 9 +- backend/db/__init__.py | 63 +++++++- backend/db/schema.sql | 37 +++-- backend/embedding/project.py | 253 +++++++++++++++++--------------- backend/pipeline.py | 34 ++++- backend/processing/dedup.py | 34 +++++ backend/topics.py | 271 +++++++++++++++++++++++++++++++++++ scripts/recluster.py | 53 +++++++ tests/conftest.py | 46 ++++-- tests/test_analyses_api.py | 45 ++++-- tests/test_migration.py | 57 +++++++- tests/test_pipeline.py | 37 +++-- tests/test_projection.py | 101 +++++++++++++ tests/test_quality_fixes.py | 43 ++---- tests/test_topics.py | 116 +++++++++++++++ 17 files changed, 1061 insertions(+), 313 deletions(-) create mode 100644 backend/topics.py create mode 100644 scripts/recluster.py create mode 100644 tests/test_projection.py create mode 100644 tests/test_topics.py diff --git a/README.md b/README.md index cd5fabc..b8432b7 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ uv run python -m scripts.export_openapi cd frontend && npm run gen:api ``` +How maps are built: `backend/topics.py` (article vectors → UMAP → HDBSCAN → c-TF-IDF, BERTopic's recipe without PyTorch) and `backend/embedding/project.py` (naming topics with Claude). + Checks: `uv run pytest`, `uv run ruff check backend tests`, and in `frontend/`: `npm run check`, `npx vitest run`, `npm run lint`. --- @@ -92,12 +94,12 @@ Checks: `uv run pytest`, `uv run ruff check backend tests`, and in `frontend/`: 1. On the home page, type a topic (e.g. `mechanistic interpretability in large language models`) and choose **Map this topic**. 2. A map takes **3–8 minutes**. You'll see each step as it happens: the search queries, pages crawled, articles kept or skipped (and why), and clusters as they're named. You can leave the page, or **Cancel this map**. 3. When it's done you get five views of the same map: - - **Map**: articles that make similar arguments sit close together; contour lines outline each cluster. Switch to **Table** for a plain list. + - **Map**: articles that make similar arguments sit close together, grouped into topics (camps) with contour lines and a name for each. Switch to **Table** for a plain list. - **Timeline**: articles per month or year, by cluster. - **Outliers**: articles furthest from the middle of their cluster. - - **Key terms**: the words each cluster uses far more than the rest. + - **Key terms**: each topic's one-line summary and the words it uses far more than the others. - **Ask**: questions answered only from this map's articles, with sources. -4. Pick a cluster in the **Clusters** panel to focus every view on it. Click any article to read it in a side panel. The address bar keeps the view, cluster and open article, so links and reloads land in the same place. +4. Pick a topic in the **Clusters** panel to focus every view on it (and read its summary). Articles that don't fit any topic well are grouped as **Unclustered** rather than forced into one. Click any article to read it in a side panel. The address bar keeps the view, cluster and open article, so links and reloads land in the same place. 5. **Export to Obsidian** downloads a vault: a Canvas of the map plus one note per article. 6. Every map is kept. **All maps** returns to the list, where you can open or delete them. @@ -126,4 +128,10 @@ Avoid topics that are too broad (`AI`, `climate change`) or too narrow (fewer th **Chat says "This analysis has no indexed corpus yet"** — The analysis hasn't completed, or no articles passed the quality filter. Try re-running with a different topic. -**Upgrading from an older version** — On first start the backend migrates the old single-corpus database into one analysis. It copies `data/discourse.db` and `data/vectors/` to `*.v1.bak` first; delete those once you're happy. +**Upgrading from an older version** — On first start the backend migrates the database. Maps made before topic modelling keep their old clusters, without key terms or summaries, until you rebuild them from the stored embeddings (no re-crawl; one Claude request per map): + +```bash +uv run python -m scripts.recluster --all +``` + +The very first migration (from the single-corpus version) copies `data/discourse.db` and `data/vectors/` to `*.v1.bak`; delete those once you're happy. diff --git a/backend/api/main.py b/backend/api/main.py index 9fa58f0..ebae999 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -83,37 +83,32 @@ class AnalysisEvent(BaseModel): url: str | None -class CorpusPoint(BaseModel): - chunk_id: str +class MapArticle(BaseModel): + """An article as placed on the map.""" + article_id: str - child_text: str | None url: str title: str | None domain: str | None publish_date: str | None + word_count: int | None x: float y: float - cluster_id: int - cluster_label: str | None - distance_from_centroid: float - word_count: int | None + cluster_id: int # -1 = unclustered + distance: float # cosine distance to its topic's centre: the outlier score -class Cluster(BaseModel): - cluster_id: int - cluster_label: str | None - article_count: int +class Term(BaseModel): + term: str + weight: float -class Outlier(BaseModel): - chunk_id: str +class Cluster(BaseModel): cluster_id: int - cluster_label: str | None - distance_from_centroid: float - article_id: str - title: str | None - domain: str | None - publish_date: str | None + label: str + description: str | None + article_count: int + terms: list[Term] class Article(BaseModel): @@ -165,36 +160,22 @@ def fetch_events(conn: sqlite3.Connection, analysis_id: str, after: int = 0) -> return [AnalysisEvent(**dict(r)) for r in rows] -def fetch_outliers( - conn: sqlite3.Connection, analysis_id: str, cluster_id: int | None = None, limit: int = 50 -) -> list[dict]: - """One row per article — its chunk furthest from its cluster centroid — - ordered by that distance, optionally restricted to one cluster.""" +def fetch_clusters(conn: sqlite3.Connection, analysis_id: str) -> list[Cluster]: rows = conn.execute( - """ - SELECT chunk_id, cluster_id, cluster_label, distance_from_centroid, - article_id, title, domain, publish_date - FROM ( - SELECT p.chunk_id, p.cluster_id, p.cluster_label, - p.distance_from_centroid, - c.article_id, a.title, a.domain, a.publish_date, - ROW_NUMBER() OVER ( - PARTITION BY c.article_id - ORDER BY p.distance_from_centroid DESC - ) AS rn - FROM projections p - JOIN chunks c ON c.id = p.chunk_id - JOIN articles a ON a.id = c.article_id - WHERE p.analysis_id = :aid AND a.status = 'ok' - AND (:cid IS NULL OR p.cluster_id = :cid) - ) - WHERE rn = 1 - ORDER BY distance_from_centroid DESC - LIMIT :limit - """, - {"aid": analysis_id, "cid": cluster_id, "limit": limit}, + "SELECT cluster_id, label, description, article_count, terms FROM clusters " + "WHERE analysis_id = ? ORDER BY cluster_id", + (analysis_id,), ).fetchall() - return [dict(r) for r in rows] + return [ + Cluster( + cluster_id=r["cluster_id"], + label=r["label"], + description=r["description"], + article_count=r["article_count"], + terms=[Term(term=t, weight=w) for t, w in json_lib.loads(r["terms"])], + ) + for r in rows + ] # --------------------------------------------------------------------------- @@ -336,57 +317,35 @@ async def event_generator(): # Analysis data # --------------------------------------------------------------------------- -@app.get("/api/analyses/{analysis_id}/corpus") -def get_corpus(analysis_id: str) -> list[CorpusPoint]: +@app.get("/api/analyses/{analysis_id}/articles") +def list_articles(analysis_id: str) -> list[MapArticle]: + """Every article on the map, with its position, topic and outlier score.""" conn = connect() try: _get_analysis(conn, analysis_id) rows = conn.execute( """ - SELECT p.chunk_id, c.article_id, c.child_text, a.url, a.title, a.domain, - a.publish_date, p.x, p.y, p.cluster_id, p.cluster_label, - p.distance_from_centroid, a.word_count - FROM projections p - JOIN chunks c ON c.id = p.chunk_id - JOIN articles a ON a.id = c.article_id - WHERE p.analysis_id = ? AND a.status = 'ok' - ORDER BY p.cluster_id, p.distance_from_centroid + SELECT p.article_id, a.url, a.title, a.domain, a.publish_date, a.word_count, + p.x, p.y, p.cluster_id, p.distance + FROM placements p + JOIN articles a ON a.id = p.article_id + WHERE p.analysis_id = ? + ORDER BY p.cluster_id, p.distance """, (analysis_id,), ).fetchall() finally: conn.close() - return [CorpusPoint(**dict(r)) for r in rows] + return [MapArticle(**dict(r)) for r in rows] @app.get("/api/analyses/{analysis_id}/clusters") def get_clusters(analysis_id: str) -> list[Cluster]: + """Topics, largest first, with their key terms. Unclustered articles have none.""" conn = connect() try: _get_analysis(conn, analysis_id) - rows = conn.execute( - """ - SELECT p.cluster_id, p.cluster_label, - COUNT(DISTINCT c.article_id) AS article_count - FROM projections p - JOIN chunks c ON c.id = p.chunk_id - WHERE p.analysis_id = ? - GROUP BY p.cluster_id, p.cluster_label - ORDER BY p.cluster_id - """, - (analysis_id,), - ).fetchall() - finally: - conn.close() - return [Cluster(**dict(r)) for r in rows] - - -@app.get("/api/analyses/{analysis_id}/outliers") -def get_outliers(analysis_id: str, cluster_id: int | None = None) -> list[Outlier]: - conn = connect() - try: - _get_analysis(conn, analysis_id) - return [Outlier(**r) for r in fetch_outliers(conn, analysis_id, cluster_id)] + return fetch_clusters(conn, analysis_id) finally: conn.close() @@ -431,36 +390,24 @@ def export_obsidian(analysis_id: str): topic = _get_analysis(conn, analysis_id)["topic"] rows = conn.execute( """ - SELECT - a.id AS article_id, a.title, a.url, a.domain, - a.publish_date, a.word_count, - AVG(p.x) AS x, AVG(p.y) AS y, - MIN(p.cluster_id) AS cluster_id, - MIN(p.cluster_label) AS cluster_label - FROM projections p - JOIN chunks c ON c.id = p.chunk_id - JOIN articles a ON a.id = c.article_id - WHERE p.analysis_id = ? AND a.status = 'ok' - GROUP BY a.id, a.title, a.url, a.domain, a.publish_date, a.word_count - ORDER BY MIN(p.cluster_id) + SELECT a.id AS article_id, a.title, a.url, a.domain, a.publish_date, + a.word_count, p.x, p.y, p.cluster_id, + COALESCE(c.label, 'Unclustered') AS cluster_label + FROM placements p + JOIN articles a ON a.id = p.article_id + LEFT JOIN clusters c + ON c.analysis_id = p.analysis_id AND c.cluster_id = p.cluster_id + WHERE p.analysis_id = ? + ORDER BY p.cluster_id < 0, p.cluster_id """, (analysis_id,), ).fetchall() rows = [dict(r) for r in rows] - clusters = conn.execute( - """ - SELECT p.cluster_label, COUNT(DISTINCT c.article_id) AS article_count - FROM projections p - JOIN chunks c ON c.id = p.chunk_id - JOIN articles a ON a.id = c.article_id - WHERE p.analysis_id = ? AND a.status = 'ok' - GROUP BY p.cluster_label - ORDER BY MIN(p.cluster_id) - """, - (analysis_id,), - ).fetchall() - clusters = [dict(r) for r in clusters] + counts: dict[str, int] = {} + for r in rows: + counts[r["cluster_label"]] = counts.get(r["cluster_label"], 0) + 1 + clusters = [{"cluster_label": k, "article_count": n} for k, n in counts.items()] finally: conn.close() diff --git a/backend/claude_client.py b/backend/claude_client.py index 5a76b36..d802bd0 100644 --- a/backend/claude_client.py +++ b/backend/claude_client.py @@ -53,6 +53,7 @@ def complete( system: str | None = None, max_tokens: int = 16000, effort: Effort = "medium", + json_schema: dict | None = None, ) -> str: """ Single-turn request; returns the concatenated text blocks. @@ -60,12 +61,18 @@ def complete( Current models think by default, so content[0] may be a thinking block — always collect the text blocks. On the Anthropic API, server-side fallback re-runs a refused request on a suitable model within the same call. + + With json_schema, the response is constrained to JSON matching it + (structured outputs); parse the returned text with json.loads. """ + output_config: dict = {"effort": effort} + if json_schema is not None: + output_config["format"] = {"type": "json_schema", "schema": json_schema} kwargs = dict( model=get_model(), max_tokens=max_tokens, messages=[{"role": "user", "content": prompt}], - output_config={"effort": effort}, + output_config=output_config, ) if system: kwargs["system"] = system diff --git a/backend/db/__init__.py b/backend/db/__init__.py index 11126fa..31bc797 100644 --- a/backend/db/__init__.py +++ b/backend/db/__init__.py @@ -18,9 +18,17 @@ log = logging.getLogger(__name__) -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 _SCHEMA = Path(__file__).parent / "schema.sql" +# Chunk-level map rows used by schema v2/v3, before placements + clusters. +_LEGACY_PROJECTIONS = """ +CREATE TABLE IF NOT EXISTS projections ( + chunk_id TEXT PRIMARY KEY, analysis_id TEXT NOT NULL, x REAL, y REAL, + cluster_id INTEGER, cluster_label TEXT, distance_from_centroid REAL +) +""" + # Analyses still in one of these states are being worked on by a pipeline. RUNNING_STATUSES = ("queued", "crawling", "processing", "embedding", "projecting") @@ -45,8 +53,11 @@ def init_db(db_path: str | None = None) -> None: _migrate_v1_to_v2(path) conn = connect(path) # v2 -> v3: new events table (created by the schema script) + stage_detail. + # v3 -> v4: clusters + placements tables (schema script), projections converted. conn.executescript(_SCHEMA.read_text()) _ensure_column(conn, "analyses", "stage_detail", "TEXT") + if _table_exists(conn, "projections"): + _migrate_projections(conn) conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") placeholders = ",".join("?" for _ in RUNNING_STATUSES) conn.execute( @@ -69,6 +80,54 @@ def _schema_statements() -> list[str]: return [s.strip() for s in "\n".join(lines).split(";") if s.strip()] +def _migrate_projections(conn: sqlite3.Connection) -> None: + """ + v3 -> v4: chunk-level projections become article placements plus a + clusters table. Each article takes the cluster most of its chunks were in + (ties: lower id), sits at the mean of those chunks, and keeps their largest + distance. Key terms and descriptions are empty until the analysis is + re-clustered (scripts/recluster.py). + """ + rows = conn.execute( + "SELECT p.analysis_id, c.article_id, p.x, p.y, p.cluster_id, p.cluster_label, " + "p.distance_from_centroid AS distance " + "FROM projections p JOIN chunks c ON c.id = p.chunk_id" + ).fetchall() + by_article: dict[str, list[sqlite3.Row]] = {} + for r in rows: + by_article.setdefault(r["article_id"], []).append(r) + + placements, labels, sizes = [], {}, {} + for article_id, chunks in by_article.items(): + votes: dict[int, int] = {} + for c in chunks: + votes[c["cluster_id"]] = votes.get(c["cluster_id"], 0) + 1 + cluster = min(votes, key=lambda k: (-votes[k], k)) + mine = [c for c in chunks if c["cluster_id"] == cluster] + analysis_id = mine[0]["analysis_id"] + placements.append(( + article_id, analysis_id, + sum(c["x"] for c in mine) / len(mine), sum(c["y"] for c in mine) / len(mine), + cluster, max(c["distance"] for c in mine), + )) + key = (analysis_id, cluster) + labels.setdefault(key, mine[0]["cluster_label"] or f"Cluster {cluster + 1}") + sizes[key] = sizes.get(key, 0) + 1 + + conn.executemany( + "INSERT OR REPLACE INTO placements (article_id, analysis_id, x, y, cluster_id, distance) " + "VALUES (?,?,?,?,?,?)", + placements, + ) + conn.executemany( + "INSERT OR REPLACE INTO clusters (analysis_id, cluster_id, label, article_count) " + "VALUES (?,?,?,?)", + [(a, c, labels[(a, c)], n) for (a, c), n in sizes.items()], + ) + conn.execute("DROP TABLE projections") + log.info("Converted %d articles' map positions to placements", len(placements)) + + def _ensure_column(conn: sqlite3.Connection, table: str, column: str, decl: str) -> None: columns = {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")} if column not in columns: @@ -128,6 +187,8 @@ def _migrate_v1_to_v2(path: str) -> None: conn.execute(f"ALTER TABLE {table} RENAME TO {table}_v1") for statement in _schema_statements(): conn.execute(statement) + # v2/v3 kept chunk-level map rows; the v3 -> v4 step converts them. + conn.execute(_LEGACY_PROJECTIONS) if analysis_id: conn.execute( diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 8dc0bbc..24377b1 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -1,5 +1,5 @@ --- Schema version 3 (PRAGMA user_version = 3): every row belongs to an analysis. --- Deleting an analysis cascades to its articles, chunks and projections +-- Schema version 4 (PRAGMA user_version = 4): every row belongs to an analysis. +-- Deleting an analysis cascades to everything it collected -- (requires PRAGMA foreign_keys = ON, set by backend.db.connect). -- Analyses: one row per topic run (formerly "jobs") @@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS articles ( word_count INTEGER, language TEXT DEFAULT 'en', raw_text TEXT, - status TEXT DEFAULT 'ok', -- ok | filtered | error + status TEXT DEFAULT 'ok', -- ok | filtered | duplicate | error UNIQUE (analysis_id, url) ); CREATE INDEX IF NOT EXISTS idx_articles_analysis ON articles(analysis_id); @@ -48,17 +48,28 @@ CREATE TABLE IF NOT EXISTS chunks ( CREATE INDEX IF NOT EXISTS idx_chunks_analysis ON chunks(analysis_id); CREATE INDEX IF NOT EXISTS idx_chunks_article ON chunks(article_id); --- Projections: 2D UMAP coords + cluster info per chunk -CREATE TABLE IF NOT EXISTS projections ( - chunk_id TEXT PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE, - analysis_id TEXT NOT NULL REFERENCES analyses(id) ON DELETE CASCADE, - x REAL, - y REAL, - cluster_id INTEGER, - cluster_label TEXT, -- LLM-generated 2-3 word label - distance_from_centroid REAL +-- Clusters: one row per topic found in an analysis (cluster_id -1 = unclustered, +-- which has no row here) +CREATE TABLE IF NOT EXISTS clusters ( + analysis_id TEXT NOT NULL REFERENCES analyses(id) ON DELETE CASCADE, + cluster_id INTEGER NOT NULL, -- 0 = largest + label TEXT NOT NULL, -- short name from Claude + description TEXT, -- one sentence from Claude + article_count INTEGER NOT NULL, + terms TEXT NOT NULL DEFAULT '[]', -- JSON [[term, weight], ...] by c-TF-IDF + PRIMARY KEY (analysis_id, cluster_id) ); -CREATE INDEX IF NOT EXISTS idx_projections_analysis ON projections(analysis_id); + +-- Placements: where each article sits on the map and which topic it belongs to +CREATE TABLE IF NOT EXISTS placements ( + article_id TEXT PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE, + analysis_id TEXT NOT NULL REFERENCES analyses(id) ON DELETE CASCADE, + x REAL NOT NULL, + y REAL NOT NULL, + cluster_id INTEGER NOT NULL, -- -1 = unclustered + distance REAL NOT NULL -- cosine distance to its topic's centre (outlier score) +); +CREATE INDEX IF NOT EXISTS idx_placements_analysis ON placements(analysis_id); -- Live log of what a pipeline run is doing; streamed to the UI over SSE. CREATE TABLE IF NOT EXISTS analysis_events ( diff --git a/backend/embedding/project.py b/backend/embedding/project.py index 0715de5..8a3513c 100644 --- a/backend/embedding/project.py +++ b/backend/embedding/project.py @@ -1,75 +1,51 @@ """ -UMAP projection + k-means clustering + cluster labelling. - -Steps: -1. Load the analysis's vectors from LanceDB. -2. Run UMAP (2D). -3. Try k-means for k in {5, 7, 10}; pick best silhouette score. -4. For each chunk compute Euclidean distance to its cluster centroid. -5. Call Claude once per cluster (top-10 central chunks) to get a 2-3 word label. -6. Write (chunk_id, x, y, cluster_id, cluster_label, distance_from_centroid) - to the SQLite projections table. +Map + topics for one analysis. + +1. Load the analysis's chunk vectors; average them into one vector per article. +2. Topic model (backend.topics): UMAP → HDBSCAN → merge/assign/cap → 2D map. +3. Key terms per topic by c-TF-IDF over the articles' text. +4. One Claude call names every topic at once, so names are distinct. +5. Replace the analysis's rows in `placements` and `clusters`. """ from __future__ import annotations +import json import logging from collections.abc import Callable import numpy as np -from sklearn.cluster import KMeans -from sklearn.metrics import silhouette_score -from umap import UMAP -from backend.config import settings +from backend import topics from backend.db import connect from backend.embedding.store import read_all_embeddings log = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _best_k(vectors: np.ndarray) -> int: - if settings.kmeans_k != "auto": - return int(settings.kmeans_k) - - candidates = [k for k in (5, 7, 10) if k < len(vectors)] - if not candidates: - return max(2, len(vectors) // 10) - - best_k, best_score = candidates[0], -1.0 - for k in candidates: - km = KMeans(n_clusters=k, n_init=10, random_state=42) - labels = km.fit_predict(vectors) - score = silhouette_score(vectors, labels) - if score > best_score: - best_k, best_score = k, score - return best_k - - -def _euclidean(a: np.ndarray, b: np.ndarray) -> float: - return float(np.linalg.norm(a - b)) - - -def _label_cluster(cluster_id: int, top_chunks: list[str]) -> str: - """Ask Claude for a 2-3 word label for this cluster.""" - from backend.claude_client import complete - - excerpts = "\n\n".join(f"- {t[:300]}" for t in top_chunks) - prompt = ( - f"Below are excerpts from articles in a semantic cluster (cluster {cluster_id}).\n\n" - f"{excerpts}\n\n" - "Give a 2-3 word descriptive label that captures the main theme of this cluster. " - "Respond with ONLY the label, nothing else." - ) - return complete(prompt, max_tokens=2048, effort="low") - +TERMS_PER_TOPIC = 10 +EXCERPT_CHARS = 400 + +LABEL_SCHEMA = { + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "label": {"type": "string"}, + "description": {"type": "string"}, + }, + "required": ["id", "label", "description"], + "additionalProperties": False, + }, + } + }, + "required": ["topics"], + "additionalProperties": False, +} -# --------------------------------------------------------------------------- -# Main entry point -# --------------------------------------------------------------------------- def run_projection( analysis_id: str, @@ -77,79 +53,130 @@ def run_projection( on_label: Callable[[str, int], None] | None = None, ) -> None: """ - Full projection pipeline. Writes rows to the SQLite projections table. + Build the map and topics for an analysis whose chunks are embedded. - on_step(fraction_done, detail) reports progress; on_label(label, n_chunks) - fires as each cluster is named. + on_step(fraction_done, detail) reports progress; on_label(label, n_articles) + fires once per topic after naming. """ + def step(fraction: float, detail: str) -> None: if on_step: on_step(fraction, detail) - # 1. Load embeddings rows = read_all_embeddings(analysis_id) if not rows: - raise RuntimeError("No embeddings found in LanceDB — run embedding step first.") - - chunk_ids = [r["chunk_id"] for r in rows] - vectors = np.array([r["vector"] for r in rows], dtype=np.float32) - child_texts = {r["chunk_id"]: r["child_text"] for r in rows} - - # 2. UMAP - step(0.0, f"Projecting {len(vectors)} chunks to 2D") - n_neighbors = min(settings.umap_n_neighbors, len(vectors) - 1) - reducer = UMAP(n_components=2, n_neighbors=n_neighbors, min_dist=0.1, random_state=42) - coords_2d = reducer.fit_transform(vectors) # shape (N, 2) - - # 3. K-means - step(0.3, "Choosing the number of clusters") - k = _best_k(vectors) - km = KMeans(n_clusters=k, n_init=10, random_state=42) - cluster_labels_arr = km.fit_predict(vectors) # int labels, shape (N,) - centroids = km.cluster_centers_ # shape (k, dim) - - # 4. Distance from centroid - distances = np.array( - [_euclidean(vectors[i], centroids[cluster_labels_arr[i]]) for i in range(len(vectors))] + raise RuntimeError("No embeddings found for this analysis — run the embedding step first.") + ids, X = topics.article_vectors( + np.array([r["vector"] for r in rows], dtype=np.float32), + [r["article_id"] for r in rows], + ) + + step(0.0, f"Finding topics among {len(ids)} articles") + labels, coords, dist = topics.model_topics(X) + + step(0.6, "Finding each topic's key terms") + articles = _load_articles(analysis_id, ids) + terms = topics.topic_terms( + [articles[i]["raw_text"] or "" for i in ids], labels, top_n=TERMS_PER_TOPIC + ) + reps = topics.representatives(X, labels, k=3) + + step(0.75, f"Naming {len(terms)} topics") + named = name_topics( + { + t: { + "terms": [w for w, _ in terms.get(t, [])], + "examples": [articles[ids[i]] for i in reps.get(t, [])], + "size": int(np.sum(labels == t)), + } + for t in sorted(terms) + } ) - # 5. Generate cluster text labels via Claude - cluster_text_labels: dict[int, str] = {} - for cid in range(k): - step(0.5 + 0.5 * cid / k, f"Labelling cluster {cid + 1} of {k}") - mask = cluster_labels_arr == cid - idxs = np.where(mask)[0] - # Sort by distance (ascending = most central) - sorted_idxs = idxs[np.argsort(distances[idxs])][:10] - top_texts = [child_texts[chunk_ids[i]] for i in sorted_idxs] - try: - cluster_text_labels[cid] = _label_cluster(cid, top_texts) - except Exception: - log.warning("Labelling cluster %s failed", cid, exc_info=True) - cluster_text_labels[cid] = f"cluster {cid}" - if on_label: - on_label(cluster_text_labels[cid], len(idxs)) - - # 6. Write projections to SQLite conn = connect() try: + conn.execute("DELETE FROM placements WHERE analysis_id = ?", (analysis_id,)) + conn.execute("DELETE FROM clusters WHERE analysis_id = ?", (analysis_id,)) + conn.executemany( + "INSERT INTO placements (article_id, analysis_id, x, y, cluster_id, distance) " + "VALUES (?,?,?,?,?,?)", + [ + (aid, analysis_id, float(coords[i, 0]), float(coords[i, 1]), + int(labels[i]), float(dist[i])) + for i, aid in enumerate(ids) + ], + ) conn.executemany( - "INSERT OR REPLACE INTO projections " - "(chunk_id, analysis_id, x, y, cluster_id, cluster_label, distance_from_centroid) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO clusters (analysis_id, cluster_id, label, description, article_count, terms) " + "VALUES (?,?,?,?,?,?)", [ - ( - chunk_ids[i], - analysis_id, - float(coords_2d[i, 0]), - float(coords_2d[i, 1]), - int(cluster_labels_arr[i]), - cluster_text_labels[int(cluster_labels_arr[i])], - float(distances[i]), - ) - for i in range(len(chunk_ids)) + (analysis_id, t, named[t][0], named[t][1], int(np.sum(labels == t)), + json.dumps([[w, round(s, 6)] for w, s in terms.get(t, [])])) + for t in sorted(terms) ], ) conn.commit() finally: conn.close() + + if on_label: + for t in sorted(terms): + on_label(named[t][0], int(np.sum(labels == t))) + + +def _load_articles(analysis_id: str, ids: list[str]) -> dict[str, dict]: + conn = connect() + try: + rows = conn.execute( + "SELECT id, title, domain, raw_text FROM articles WHERE analysis_id = ?", + (analysis_id,), + ).fetchall() + finally: + conn.close() + found = {r["id"]: dict(r) for r in rows} + return {i: found.get(i, {"id": i, "title": None, "domain": None, "raw_text": ""}) for i in ids} + + +def name_topics(topics_in: dict[int, dict]) -> dict[int, tuple[str, str | None]]: + """ + Name every topic in one request so the names contrast with each other — + naming topics one at a time gave near-identical names to neighbours. + Falls back to the top key terms if the request fails. + """ + from backend.claude_client import complete + + fallback = { + t: (", ".join(info["terms"][:3]).capitalize() or f"Topic {t + 1}", None) + for t, info in topics_in.items() + } + if not topics_in: + return {} + + blocks = [] + for t, info in topics_in.items(): + examples = "\n".join( + f" - {(a['title'] or a['domain'] or 'Untitled')}: " + f"{' '.join((a['raw_text'] or '').split())[:EXCERPT_CHARS]}" + for a in info["examples"] + ) + blocks.append( + f"Topic {t} ({info['size']} articles)\n" + f"Key terms: {', '.join(info['terms'])}\n" + f"Most typical articles:\n{examples}" + ) + prompt = ( + "These topics were found by clustering articles about one subject. For each topic, " + "write a label of 2 to 4 words naming the angle or camp it represents, and one " + "sentence (under 25 words) describing what its articles argue or cover.\n\n" + "Labels must be clearly different from each other: they will sit side by side on a " + "map, so name what sets each topic apart rather than the subject they all share. " + "Use sentence case.\n\n" + "\n\n".join(blocks) + ) + try: + data = json.loads(complete(prompt, max_tokens=8000, effort="low", json_schema=LABEL_SCHEMA)) + named = {int(item["id"]): (item["label"].strip(), item["description"].strip()) + for item in data["topics"]} + except Exception: + log.warning("Naming topics failed; using key terms instead", exc_info=True) + return fallback + return {t: named.get(t) or fallback[t] for t in topics_in} diff --git a/backend/pipeline.py b/backend/pipeline.py index 2351091..0b1abc4 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -232,14 +232,38 @@ def _extract_and_store(ctx: RunContext, crawled: list[dict]) -> list[dict]: ), ) conn.commit() - ok_articles.append({"id": article["id"], "raw_text": article["raw_text"]}) + ok_articles.append({ + "id": article["id"], "url": url, "raw_text": article["raw_text"], + "title": article["title"] or article["domain"], + }) ctx.event("article", article["title"] or article["domain"], url) ctx.progress(EXTRACT, i / len(crawled), f"Extracted {i} of {len(crawled)} pages") finally: conn.close() if not ok_articles: raise RuntimeError("No pages passed the quality filters — try a broader topic.") - return ok_articles + return _drop_duplicate_articles(ctx, ok_articles) + + +def _drop_duplicate_articles(ctx: RunContext, articles: list[dict]) -> list[dict]: + """Mark near-copies of an earlier article (e.g. syndicated stories) and drop them.""" + from backend.processing.dedup import find_duplicate_articles + + duplicates = find_duplicate_articles(articles) + if not duplicates: + return articles + by_id = {a["id"]: a for a in articles} + conn = connect() + try: + conn.executemany( + "UPDATE articles SET status = 'duplicate' WHERE id = ?", [(d,) for d in duplicates] + ) + conn.commit() + finally: + conn.close() + for dup, original in duplicates.items(): + ctx.event("skip", f"Near-copy of “{by_id[original]['title']}”", by_id[dup]["url"]) + return [a for a in articles if a["id"] not in duplicates] def _chunk_and_store(ctx: RunContext, articles: list[dict]) -> None: @@ -316,8 +340,8 @@ def on_step(fraction: float, detail: str) -> None: ctx.check() ctx.progress(PROJECT, fraction, detail) - def on_label(label: str, n_chunks: int) -> None: - ctx.event("cluster", f"{label} ({n_chunks} chunks)") + def on_label(label: str, n_articles: int) -> None: + ctx.event("cluster", f"{label} ({n_articles} articles)") run_projection(ctx.analysis_id, on_step=on_step, on_label=on_label) @@ -327,7 +351,7 @@ def _discard_partial_data(analysis_id: str) -> None: conn = connect() try: - for table in ("projections", "chunks", "articles"): + for table in ("placements", "clusters", "chunks", "articles"): conn.execute(f"DELETE FROM {table} WHERE analysis_id = ?", (analysis_id,)) conn.commit() finally: diff --git a/backend/processing/dedup.py b/backend/processing/dedup.py index c0f8796..e23d74f 100644 --- a/backend/processing/dedup.py +++ b/backend/processing/dedup.py @@ -58,6 +58,40 @@ def mark_duplicates(chunks: list[dict]) -> list[dict]: return chunks +ARTICLE_THRESHOLD = 0.8 + + +def _word_shingles(text: str, k: int = 3) -> set[bytes]: + words = re.sub(r"\s+", " ", text.lower()).split(" ") + return {" ".join(words[i : i + k]).encode("utf-8") for i in range(max(1, len(words) - k + 1))} + + +def find_duplicate_articles( + articles: list[dict], threshold: float = ARTICLE_THRESHOLD +) -> dict[str, str]: + """ + Whole-article near-duplicates (dicts with 'id' and 'raw_text'), e.g. one + wire story syndicated across sites with different intros or footers — + chunk dedup misses these because shifted text misaligns every chunk. + Returns {duplicate_id: id_of_the_earlier_article_it_copies}. + """ + lsh = MinHashLSH(threshold=threshold, num_perm=NUM_PERM) + duplicates: dict[str, str] = {} + for art in articles: + text = art.get("raw_text") or "" + if len(text.split()) < 20: + continue + m = MinHash(num_perm=NUM_PERM) + for shingle in _word_shingles(text): + m.update(shingle) + match = lsh.query(m) + if match: + duplicates[art["id"]] = match[0] + else: + lsh.insert(art["id"], m) + return duplicates + + def chunk_and_dedup(articles: list[dict]) -> list[dict]: """ Chunk every article (dicts with 'id' and 'raw_text'), then dedup across the diff --git a/backend/topics.py b/backend/topics.py new file mode 100644 index 0000000..fee60cf --- /dev/null +++ b/backend/topics.py @@ -0,0 +1,271 @@ +""" +Topic modelling for one analysis: BERTopic's recipe without its PyTorch dependency. + +Articles (not chunks) are the unit: chunks of one article are so similar that +chunk-level clustering groups text by source instead of by argument. + + article vectors (mean of chunk embeddings, L2-normalised) + → UMAP to 5D → HDBSCAN (leaf selection: the finest stable clusters) + → merge topics that members can't tell apart (leaf selection over-splits) + → noise articles join their nearest topic only if they'd pass as a member + → topics merged down to MAX_TOPICS (the number of distinct map colours) + → c-TF-IDF over each topic's text for its key terms + +All functions here are pure (numpy in, numpy/py out) so they're unit-tested +without LanceDB, SQLite or the network. +""" + +from __future__ import annotations + +import re + +import numpy as np +from sklearn.cluster import HDBSCAN, AgglomerativeClustering +from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS, CountVectorizer + +NOISE = -1 +MAX_TOPICS = 8 +RANDOM_STATE = 42 + + +def normalize(vectors: np.ndarray) -> np.ndarray: + norms = np.linalg.norm(vectors, axis=1, keepdims=True) + return vectors / np.where(norms == 0, 1, norms) + + +def article_vectors( + chunk_vectors: np.ndarray, chunk_article_ids: list[str] +) -> tuple[list[str], np.ndarray]: + """Mean of each article's chunk vectors, normalised. Returns (ids, vectors).""" + order: dict[str, list[int]] = {} + for i, aid in enumerate(chunk_article_ids): + order.setdefault(aid, []).append(i) + ids = list(order) + X = np.stack([chunk_vectors[idx].mean(axis=0) for idx in order.values()]) + return ids, normalize(X.astype(np.float32)) + + +def reduce( + X: np.ndarray, dims: int, min_dist: float, neighbors: int = 10, spread: float = 1.0 +) -> np.ndarray: + """UMAP with cosine distance; neighbourhood size adapts to small corpora.""" + from umap import UMAP # slow import (numba); keep it out of module import + + n = len(X) + if n <= dims + 1: + # Too few points for UMAP's spectral init; pad with zeros (degenerate but valid). + return np.pad(X[:, :dims], ((0, 0), (0, max(0, dims - X.shape[1])))) + reducer = UMAP( + n_components=dims, + n_neighbors=max(2, min(neighbors, n - 1)), + min_dist=min_dist, + spread=spread, + metric="cosine", + random_state=RANDOM_STATE, + n_jobs=1, # implied by random_state; explicit to avoid a warning + ) + return reducer.fit_transform(X) + + +def find_topics(Z: np.ndarray) -> np.ndarray: + """ + HDBSCAN with leaf selection on the reduced space; label -1 is noise. + Minimum cluster size grows slowly with corpus size (3 for ~50 articles, + 8 for ~120). If density finds fewer than two topics (tiny or very uniform + corpora), fall back to Ward clustering into three. + """ + n = len(Z) + if n < 6: + return np.zeros(n, dtype=int) + min_size = max(3, round(n / 15)) + labels = HDBSCAN(min_cluster_size=min_size, cluster_selection_method="leaf").fit_predict(Z) + if len(set(labels) - {NOISE}) < 2: + labels = AgglomerativeClustering(n_clusters=min(3, n), linkage="ward").fit_predict(Z) + return relabel(labels) + + +def relabel(labels: np.ndarray) -> np.ndarray: + """Renumber topics 0..k-1 by descending size (noise stays -1).""" + ids = [t for t in set(labels.tolist()) if t != NOISE] + ids.sort(key=lambda t: (-int(np.sum(labels == t)), t)) + mapping = {old: new for new, old in enumerate(ids)} + return np.array([mapping.get(t, NOISE) for t in labels.tolist()], dtype=int) + + +def centroids(X: np.ndarray, labels: np.ndarray) -> dict[int, np.ndarray]: + return { + int(t): normalize(X[labels == t].mean(axis=0, keepdims=True))[0] + for t in sorted(set(labels.tolist()) - {NOISE}) + } + + +def merge_indistinct(X: np.ndarray, labels: np.ndarray, tolerance: float = 0.01) -> np.ndarray: + """ + Merge pairs of topics whose centroids are as similar to each other as the + topics' own members are to their centroids (median, less a small tolerance + for noise in that estimate): such a split carries no information. Leaf + selection otherwise cuts evenly spread groups into arbitrary sub-leaves. + """ + labels = labels.copy() + while True: + cents = centroids(X, labels) + ids = list(cents) + if len(ids) < 2: + return relabel(labels) + cohesion = {t: float(np.median(X[labels == t] @ cents[t])) for t in ids} + best, pair = -np.inf, None + for i, a in enumerate(ids): + for b in ids[i + 1 :]: + margin = float(cents[a] @ cents[b]) - min(cohesion[a], cohesion[b]) + if margin > best: + best, pair = margin, (a, b) + if best < -tolerance: + return relabel(labels) + labels[labels == pair[1]] = pair[0] + + +def assign_noise(X: np.ndarray, labels: np.ndarray) -> np.ndarray: + """ + A noise article joins its most similar topic if it's at least as similar + to that topic's centroid as the topic's least-typical member. Otherwise it + stays unclustered. + """ + labels = labels.copy() + cents = centroids(X, labels) + if not cents: + return labels + floor = {t: float((X[labels == t] @ c).min()) for t, c in cents.items()} + topic_ids = list(cents) + C = np.stack([cents[t] for t in topic_ids]) + for i in np.where(labels == NOISE)[0]: + sims = C @ X[i] + best = int(np.argmax(sims)) + if sims[best] >= floor[topic_ids[best]]: + labels[i] = topic_ids[best] + return labels + + +def cap_topics(X: np.ndarray, labels: np.ndarray, max_topics: int = MAX_TOPICS) -> np.ndarray: + """Merge the two most similar topics (by centroid) until at most max_topics remain.""" + labels = labels.copy() + while len(set(labels.tolist()) - {NOISE}) > max_topics: + cents = centroids(X, labels) + ids = list(cents) + C = np.stack([cents[t] for t in ids]) + sim = C @ C.T + np.fill_diagonal(sim, -np.inf) + a, b = np.unravel_index(int(np.argmax(sim)), sim.shape) + labels[labels == ids[b]] = ids[a] + return relabel(labels) + + +def distances(X: np.ndarray, labels: np.ndarray) -> np.ndarray: + """Cosine distance of each article to its topic centroid (nearest one for noise).""" + cents = centroids(X, labels) + if not cents: + return np.zeros(len(X)) + ids = list(cents) + C = np.stack([cents[t] for t in ids]) + sims = X @ C.T + out = np.empty(len(X)) + for i, t in enumerate(labels.tolist()): + out[i] = 1 - (sims[i, ids.index(t)] if t != NOISE else sims[i].max()) + return out + + +def representatives(X: np.ndarray, labels: np.ndarray, k: int = 3) -> dict[int, list[int]]: + """Indices of the k articles closest to each topic centroid.""" + out = {} + for t, c in centroids(X, labels).items(): + members = np.where(labels == t)[0] + out[t] = members[np.argsort(-(X[members] @ c))][:k].tolist() + return out + + +_URL = re.compile(r"\b(?:https?://|www\.)\S+") + +# Web-page and citation boilerplate that survives text extraction. +BOILERPLATE = frozenset( + """ + abstract accessed al archive archived article articles bibcode cite cited citation + click cookie cookies copyright doi download edit et fig figure figures html http + https isbn issn journal menu new opens original page pages pdf pmc pmid pp press + privacy published publisher read retrieved share site subscribe tab table vol + volume website wikipedia www + acad biol chem curr econ environ int lett med natl nat proc res rev sci soc + january february march april may june july august september october november december + """.split() +) + + +def topic_terms( + texts: list[str], labels: np.ndarray, top_n: int = 10 +) -> dict[int, list[tuple[str, float]]]: + """ + c-TF-IDF (BERTopic): treat each topic's text as one document. A term's + weight is its frequency in the topic times log(1 + A / f), where A is the + average word count per topic and f the term's frequency across all topics, + so words common everywhere sink and words particular to a topic rise. + + `texts` holds one string per article. A term must also appear in at least + two of a topic's articles, so one page's menus, references or author names + can't define a topic. + """ + topics_ = sorted(set(labels.tolist()) - {NOISE}) + if not topics_: + return {} + vectorizer = CountVectorizer( + stop_words=list(ENGLISH_STOP_WORDS | BOILERPLATE), + ngram_range=(1, 2), + token_pattern=r"(?u)\b[a-zA-Z][a-zA-Z-]+[a-zA-Z]\b", # no apostrophes: "it's" isn't a term + lowercase=True, + ) + per_article = vectorizer.fit_transform([_URL.sub(" ", t) for t in texts]).tocsr() + vocab = vectorizer.get_feature_names_out() + + counts = np.vstack([np.asarray(per_article[labels == t].sum(axis=0)) for t in topics_]) + spread = np.vstack([np.asarray((per_article[labels == t] > 0).sum(axis=0)) for t in topics_]) + + tf = counts / np.maximum(counts.sum(axis=1, keepdims=True), 1) + avg_words = counts.sum() / len(topics_) + idf = np.log(1 + avg_words / np.maximum(counts.sum(axis=0), 1)) + scores = tf * idf + if len(topics_) > 1: + # A term used by every topic can't tell them apart. + scores[:, (counts > 0).all(axis=0)] = 0 + for row, t in enumerate(topics_): + min_articles = min(2, int(np.sum(labels == t))) + scores[row, spread[row] < min_articles] = 0 + + out = {} + for row, t in enumerate(topics_): + picked: list[tuple[str, float]] = [] + for j in np.argsort(-scores[row])[: top_n * 3]: + if scores[row, j] <= 0: + break + term = str(vocab[j]) + # Skip a unigram already covered by a stronger bigram, and vice versa. + if any(term in p or p in term for p, _ in picked): + continue + picked.append((term, float(scores[row, j]))) + if len(picked) == top_n: + break + out[t] = picked + return out + + +def model_topics(X: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + The full topic model for normalised article vectors. + Returns (labels, 2D map coordinates, distance to topic centroid). + """ + Z5 = reduce(X, dims=5, min_dist=0.0) + labels = find_topics(Z5) + labels = merge_indistinct(X, labels) + labels = assign_noise(X, labels) + labels = cap_topics(X, labels) + # Map layout: a wider neighbourhood and looser packing than the clustering + # space, so topics get room on screen instead of crowding into a few islands + # (measured on real corpora: ~2.5x larger topic areas, still clearly separated). + coords = reduce(X, dims=2, min_dist=0.8, neighbors=20, spread=1.5) + return labels, coords, distances(X, labels) diff --git a/scripts/recluster.py b/scripts/recluster.py new file mode 100644 index 0000000..ed0b257 --- /dev/null +++ b/scripts/recluster.py @@ -0,0 +1,53 @@ +""" +Rebuild the map and topics of finished analyses from their stored embeddings, +without crawling again. Use after changing the topic model, or to upgrade maps +made by older versions. + + uv run python -m scripts.recluster [ ...] + uv run python -m scripts.recluster --all + +Each run makes one Claude request per analysis (to name its topics). +""" + +from __future__ import annotations + +import argparse +import sys + +from backend.db import connect, init_db +from backend.embedding.project import run_projection + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("ids", nargs="*", help="analysis ids (prefixes are fine)") + parser.add_argument("--all", action="store_true", help="every finished analysis") + args = parser.parse_args(argv) + if not args.ids and not args.all: + parser.error("give analysis ids or --all") + + init_db() + conn = connect() + done = conn.execute( + "SELECT id, topic FROM analyses WHERE status = 'done' ORDER BY created_ts" + ).fetchall() + conn.close() + targets = done if args.all else [ + a for a in done if any(a["id"].startswith(prefix) for prefix in args.ids) + ] + if not targets: + print("No matching finished analyses.", file=sys.stderr) + return 1 + + for a in targets: + print(f"\n{a['topic']} ({a['id'][:8]})") + run_projection( + a["id"], + on_step=lambda _f, detail: print(f" {detail}"), + on_label=lambda label, n: print(f" {label} ({n} articles)"), + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index 9089c2b..9723a7d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +import json + import pytest from backend.config import settings @@ -24,21 +26,49 @@ def add_analysis(analysis_id, topic="topic", status="done"): conn.close() -def add_chunk(analysis_id, article_id, chunk_id, cluster_id=0, distance=1.0, label="zero"): - """Insert an article (if new), one chunk and its projection.""" +def add_article(analysis_id, article_id, cluster_id=0, distance=0.1, x=0.0, y=0.0, chunks=1): + """Insert an article, `chunks` chunks of it, and its map placement.""" conn = connect() conn.execute( - "INSERT OR IGNORE INTO articles (id, analysis_id, url, title, status) VALUES (?,?,?,?,?)", + "INSERT INTO articles (id, analysis_id, url, title, status) VALUES (?,?,?,?,?)", (article_id, analysis_id, f"https://example.com/{article_id}", article_id.upper(), "ok"), ) - conn.execute( + conn.executemany( "INSERT INTO chunks (id, analysis_id, article_id, child_text) VALUES (?,?,?,?)", - (chunk_id, analysis_id, article_id, f"text of {chunk_id}"), + [(f"{article_id}#{i}", analysis_id, article_id, f"text {i}") for i in range(chunks)], + ) + conn.execute( + "INSERT INTO placements (article_id, analysis_id, x, y, cluster_id, distance) " + "VALUES (?,?,?,?,?,?)", + (article_id, analysis_id, x, y, cluster_id, distance), ) + conn.commit() + conn.close() + + +def add_cluster(analysis_id, cluster_id, label, article_count=1, terms=(), description=None): + conn = connect() conn.execute( - "INSERT INTO projections (chunk_id, analysis_id, x, y, cluster_id, cluster_label, " - "distance_from_centroid) VALUES (?,?,?,?,?,?,?)", - (chunk_id, analysis_id, 0.0, 0.0, cluster_id, label, distance), + "INSERT INTO clusters (analysis_id, cluster_id, label, description, article_count, terms) " + "VALUES (?,?,?,?,?,?)", + (analysis_id, cluster_id, label, description, article_count, json.dumps(list(terms))), ) conn.commit() conn.close() + + +_VOCAB = ( + "energy efficiency demand rebound policy price carbon household city heat roof tree " + "climate study report evidence cost growth consumption supply market tax standard data " + "model risk exposure equity income region urban rural building water power grid fuel " + "coal oil gas solar wind storage transport vehicle industry economy research analysis" +).split() + + +def prose(seed: int, words: int = 400) -> str: + """Varied, article-length filler text (repeating one sentence is unrealistic: + it has only a handful of distinct word sequences).""" + import random + + rng = random.Random(seed) + return " ".join(rng.choice(_VOCAB) for _ in range(words)) + "." diff --git a/tests/test_analyses_api.py b/tests/test_analyses_api.py index 43c82c9..c67a3d8 100644 --- a/tests/test_analyses_api.py +++ b/tests/test_analyses_api.py @@ -1,5 +1,5 @@ import pytest -from conftest import add_analysis, add_chunk +from conftest import add_analysis, add_article, add_cluster from fastapi.testclient import TestClient from backend.api import main @@ -35,27 +35,43 @@ def test_blank_topic_rejected(client): def test_unknown_analysis_is_404_with_string_detail(client): - r = client.get("/api/analyses/nope/corpus") + r = client.get("/api/analyses/nope/articles") assert r.status_code == 404 assert r.json() == {"detail": "Analysis not found"} -def test_corpus_is_scoped_to_analysis(client): +def test_articles_are_scoped_to_analysis(client): add_analysis("an1") add_analysis("an2") - add_chunk("an1", "a1", "a1#0") - add_chunk("an2", "b1", "b1#0") + add_article("an1", "a1", x=1.5, y=-2.0, distance=0.3) + add_article("an2", "b1") + + articles = client.get("/api/analyses/an1/articles").json() + assert [a["article_id"] for a in articles] == ["a1"] + assert articles[0] | {"url": None} == { + "article_id": "a1", "url": None, "title": "A1", "domain": None, + "publish_date": None, "word_count": None, "x": 1.5, "y": -2.0, + "cluster_id": 0, "distance": 0.3, + } + - points = client.get("/api/analyses/an1/corpus").json() - assert [p["article_id"] for p in points] == ["a1"] - clusters = client.get("/api/analyses/an2/clusters").json() - assert clusters == [{"cluster_id": 0, "cluster_label": "zero", "article_count": 1}] +def test_clusters_carry_terms_and_description(client): + add_analysis("an1") + add_cluster("an1", 1, "Heat equity", 4, [["gentrification", 0.12]], "Who bears the heat.") + add_cluster("an1", 0, "Cool roofs", 9) + + clusters = client.get("/api/analyses/an1/clusters").json() + assert [c["label"] for c in clusters] == ["Cool roofs", "Heat equity"] + assert clusters[1] == { + "cluster_id": 1, "label": "Heat equity", "description": "Who bears the heat.", + "article_count": 4, "terms": [{"term": "gentrification", "weight": 0.12}], + } def test_article_must_belong_to_analysis(client): add_analysis("an1") add_analysis("an2") - add_chunk("an1", "a1", "a1#0") + add_article("an1", "a1") assert client.get("/api/analyses/an1/articles/a1").status_code == 200 assert client.get("/api/analyses/an2/articles/a1").status_code == 404 @@ -63,8 +79,9 @@ def test_article_must_belong_to_analysis(client): def test_delete_cascades_rows_and_vectors(client): add_analysis("an1") add_analysis("an2") - add_chunk("an1", "a1", "a1#0") - add_chunk("an2", "b1", "b1#0") + add_article("an1", "a1") + add_cluster("an1", 0, "Zero") + add_article("an2", "b1") store.write_embeddings("an1", [{"chunk_id": "a1#0", "article_id": "a1", "vector": [0.1, 0.2]}]) assert client.delete("/api/analyses/an1").status_code == 204 @@ -72,11 +89,11 @@ def test_delete_cascades_rows_and_vectors(client): conn = connect() counts = { t: conn.execute(f"SELECT COUNT(*) FROM {t} WHERE analysis_id='an1'").fetchone()[0] - for t in ("articles", "chunks", "projections") + for t in ("articles", "chunks", "placements", "clusters") } other = conn.execute("SELECT COUNT(*) FROM chunks WHERE analysis_id='an2'").fetchone()[0] conn.close() - assert counts == {"articles": 0, "chunks": 0, "projections": 0} + assert counts == {"articles": 0, "chunks": 0, "placements": 0, "clusters": 0} assert other == 1 assert store.open_table("an1") is None assert client.get("/api/analyses/an1").status_code == 404 diff --git a/tests/test_migration.py b/tests/test_migration.py index e67ead2..d442523 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -1,4 +1,4 @@ -"""v1 (single global corpus + jobs table) → v2 (analyses).""" +"""Schema migrations: v1 (one corpus + jobs) → v2 (analyses) → … → v4 (placements, clusters).""" import sqlite3 @@ -65,13 +65,18 @@ def test_corpus_becomes_latest_finished_job(tmp_path, monkeypatch): assert analyses[0]["id"] == "job-1" assert analyses[0]["topic"] == "jevons paradox" assert analyses[0]["warning"] == "sparse" - for table in ("articles", "chunks", "projections"): + for table in ("articles", "chunks", "placements", "clusters"): ids = {r[0] for r in conn.execute(f"SELECT analysis_id FROM {table}")} assert ids == {"job-1"}, table - assert conn.execute("PRAGMA user_version").fetchone()[0] == 3 + placement = dict(conn.execute("SELECT * FROM placements").fetchone()) + assert placement | {"analysis_id": None} == { + "article_id": "a1", "analysis_id": None, "x": 1.0, "y": 2.0, "cluster_id": 0, "distance": 0.5, + } + assert conn.execute("SELECT label FROM clusters").fetchone()[0] == "Label" + assert conn.execute("PRAGMA user_version").fetchone()[0] == 4 tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} conn.close() - assert "jobs" not in tables and "articles_v1" not in tables + assert not tables & {"jobs", "articles_v1", "projections"} rows = store.read_all_embeddings("job-1") assert [r["chunk_id"] for r in rows] == ["a1#p0c0"] @@ -107,3 +112,47 @@ def test_v1_without_warning_column(tmp_path, monkeypatch): row = conn.execute("SELECT id, warning FROM analyses").fetchone() conn.close() assert (row["id"], row["warning"]) == ("job-1", None) + + +def test_v3_projections_become_article_placements(tmp_path, monkeypatch): + """Each article takes its majority cluster and the mean position of those chunks.""" + monkeypatch.setattr(settings, "db_path", str(tmp_path / "v3.db")) + monkeypatch.setattr(settings, "lance_path", str(tmp_path / "vectors")) + init_db() + conn = connect() + conn.execute( + "INSERT INTO analyses (id, topic, status, created_ts, updated_ts) " + "VALUES ('an', 't', 'done', 1, 1)" + ) + conn.execute("INSERT INTO articles (id, analysis_id, url) VALUES ('a', 'an', 'u')") + conn.executemany( + "INSERT INTO chunks (id, analysis_id, article_id) VALUES (?, 'an', 'a')", + [("a#0",), ("a#1",), ("a#2",)], + ) + conn.execute( + "CREATE TABLE projections (chunk_id TEXT PRIMARY KEY, analysis_id TEXT, x REAL, y REAL, " + "cluster_id INTEGER, cluster_label TEXT, distance_from_centroid REAL)" + ) + conn.executemany( + "INSERT INTO projections VALUES (?, 'an', ?, ?, ?, ?, ?)", + [ + ("a#0", 2.0, 2.0, 1, "One", 0.4), + ("a#1", 4.0, 6.0, 1, "One", 0.7), + ("a#2", 99.0, 99.0, 0, "Zero", 9.0), + ], + ) + conn.execute("PRAGMA user_version = 3") + conn.commit() + conn.close() + + init_db() + conn = connect() + p = dict(conn.execute("SELECT x, y, cluster_id, distance FROM placements").fetchone()) + clusters = [tuple(r) for r in conn.execute("SELECT cluster_id, label, article_count FROM clusters")] + has_projections = conn.execute( + "SELECT 1 FROM sqlite_master WHERE name='projections'" + ).fetchone() + conn.close() + assert p == {"x": 3.0, "y": 4.0, "cluster_id": 1, "distance": 0.7} + assert clusters == [(1, "One", 1)] + assert has_projections is None diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index dae7a38..b91a3cc 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -8,14 +8,15 @@ import time import pytest -from conftest import add_analysis +from conftest import add_analysis, prose from backend import pipeline from backend.db import connect from backend.embedding import store from backend.ingestion.extractor import SkipArticle -WORDS = "rebound effects mean efficiency gains can raise total energy demand over time " +TEXTS = {"1": prose(1), "2": prose(2)} +TEXTS["copy"] = "Syndicated from the wire. " + TEXTS["1"] # near-copy of article 1 @pytest.fixture @@ -27,7 +28,7 @@ def collect_urls(topic, on_query=None): if on_query: on_query(1, 2, "query one", 3) on_query(2, 2, "query two", None) - return ["https://a.test/1", "https://a.test/2", "https://a.test/short"] + return ["https://a.test/1", "https://a.test/2", "https://a.test/copy", "https://a.test/short"] def crawl_urls(urls, on_page=None, should_stop=None): if state["crawl_hook"]: @@ -47,7 +48,7 @@ def extract_article(analysis_id, url, html): n = url.rsplit("/", 1)[1] return { "id": f"{analysis_id[:4]}-{n}", "url": url, "domain": "a.test", - "title": f"Article {n}", "raw_text": (WORDS + n + " ") * 60, + "title": f"Article {n}", "raw_text": TEXTS[n], "word_count": 840, "language": "en", "crawl_ts": 0, "status": "ok", } @@ -62,16 +63,22 @@ def embed_chunks(chunks, kind="document", on_batch=None): def run_projection(analysis_id, on_step=None, on_label=None): on_step(0.0, "Projecting") conn = connect() - ids = [r[0] for r in conn.execute( - "SELECT id FROM chunks WHERE analysis_id=? AND is_duplicate=0", (analysis_id,))] + articles = [r[0] for r in conn.execute( + "SELECT DISTINCT article_id FROM chunks WHERE analysis_id=? AND is_duplicate=0", + (analysis_id,), + )] conn.executemany( - "INSERT INTO projections (chunk_id, analysis_id, x, y, cluster_id, cluster_label, " - "distance_from_centroid) VALUES (?,?,0,0,0,'Rebound',1)", - [(i, analysis_id) for i in ids], + "INSERT INTO placements (article_id, analysis_id, x, y, cluster_id, distance) " + "VALUES (?,?,0,0,0,0.1)", + [(a, analysis_id) for a in articles], + ) + conn.execute( + "INSERT INTO clusters (analysis_id, cluster_id, label, article_count) VALUES (?,0,'Rebound',?)", + (analysis_id, len(articles)), ) conn.commit() conn.close() - on_label("Rebound", len(ids)) + on_label("Rebound", len(articles)) monkeypatch.setattr("backend.ingestion.search.collect_urls", collect_urls) monkeypatch.setattr("backend.ingestion.crawler.crawl_urls", crawl_urls) @@ -121,6 +128,12 @@ def test_happy_path_records_progress_and_events(fakes): assert {"stage", "query", "article", "skip", "cluster", "warning"} <= kinds assert {"kind": "skip", "message": "Too short (12 words)", "url": "https://a.test/short"} in events + assert {"kind": "skip", "message": "Near-copy of “Article 1”", + "url": "https://a.test/copy"} in events + conn = connect() + status = conn.execute("SELECT status FROM articles WHERE url='https://a.test/copy'").fetchone()[0] + conn.close() + assert status == "duplicate" assert any(e["kind"] == "warning" and "query two" in e["message"] for e in events) assert store.open_table("an1") is not None @@ -138,7 +151,7 @@ def wait_for_cancel(should_stop): async def scenario(): pipeline.start("an1", "jevons") task, _ctx = pipeline._runs["an1"] - while _analysis("an1")["stage_detail"] != "Crawling 3 pages": + while _analysis("an1")["stage_detail"] != "Crawling 4 pages": await asyncio.sleep(0.01) assert pipeline.cancel("an1") is True await task @@ -160,7 +173,7 @@ def test_cancel_inside_worker_thread_propagates(fakes): asyncio.run(pipeline.run_pipeline(ctx, "jevons")) assert _analysis("an1")["status"] == "cancelled" - for table in ("articles", "chunks", "projections"): + for table in ("articles", "chunks", "placements", "clusters"): assert _count(table, "an1") == 0, table assert store.open_table("an1") is None diff --git a/tests/test_projection.py b/tests/test_projection.py new file mode 100644 index 0000000..8b6aa1f --- /dev/null +++ b/tests/test_projection.py @@ -0,0 +1,101 @@ +"""run_projection end to end on synthetic embeddings, with the Claude call stubbed.""" + +import json + +from conftest import add_analysis, prose +from test_topics import blobs + +from backend.db import connect +from backend.embedding import project, store + +MARKERS = ["alphaword", "betaword", "gammaword"] # letters only: numbers never become terms + + +def _seed(analysis_id="an1", centers=3, n_per=8): + """Articles in `centers` well-separated groups, two chunks each, embedded.""" + X, truth = blobs(n_per=n_per, centers=centers, dim=16) + add_analysis(analysis_id) + conn = connect() + rows = [] + for i, (vec, group) in enumerate(zip(X, truth, strict=True)): + aid = f"a{i}" + conn.execute( + "INSERT INTO articles (id, analysis_id, url, title, raw_text, status) VALUES (?,?,?,?,?,?)", + (aid, analysis_id, f"https://x.test/{i}", f"Article {i}", + f"{MARKERS[group]} " * 60 + prose(int(group)), "ok"), + ) + for c in range(2): + rows.append({"chunk_id": f"{aid}#{c}", "article_id": aid, "vector": vec.tolist()}) + conn.commit() + conn.close() + store.write_embeddings(analysis_id, rows) + return truth + + +def _fake_complete(prompt, **kwargs): + ids = sorted({int(line.split()[1]) for line in prompt.splitlines() if line.startswith("Topic ")}) + return json.dumps({"topics": [ + {"id": t, "label": f"Camp {t}", "description": f"About camp {t}."} for t in ids + ]}) + + +def test_projection_writes_placements_and_named_clusters(isolated, monkeypatch): + truth = _seed() + monkeypatch.setattr("backend.claude_client.complete", _fake_complete) + labelled = [] + + project.run_projection("an1", on_label=lambda label, n: labelled.append((label, n))) + + conn = connect() + placements = conn.execute("SELECT article_id, cluster_id FROM placements").fetchall() + clusters = [dict(r) for r in conn.execute("SELECT * FROM clusters ORDER BY cluster_id")] + conn.close() + + assert len(placements) == len(truth) + assert [c["label"] for c in clusters] == ["Camp 0", "Camp 1", "Camp 2"] + assert all(c["description"].startswith("About camp") for c in clusters) + assert sum(c["article_count"] for c in clusters) == len(truth) + # each topic's strongest key term is its marker word (possibly as a bigram) + tops = [json.loads(c["terms"])[0][0] for c in clusters] + assert sorted(next(m for m in MARKERS if m in t) for t in tops) == MARKERS + assert labelled == [(c["label"], c["article_count"]) for c in clusters] + + +def test_naming_failure_falls_back_to_key_terms(isolated, monkeypatch): + _seed() + + def boom(*a, **k): + raise RuntimeError("API down") + + monkeypatch.setattr("backend.claude_client.complete", boom) + project.run_projection("an1") + + conn = connect() + labels = [r[0] for r in conn.execute("SELECT label FROM clusters")] + conn.close() + assert len(labels) == 3 + assert all("word" in label.lower() for label in labels) + + +def test_rerun_replaces_previous_results(isolated, monkeypatch): + _seed() + monkeypatch.setattr("backend.claude_client.complete", _fake_complete) + project.run_projection("an1") + project.run_projection("an1") + conn = connect() + n_clusters = conn.execute("SELECT COUNT(*) FROM clusters").fetchone()[0] + n_placements = conn.execute("SELECT COUNT(*) FROM placements").fetchone()[0] + conn.close() + assert (n_clusters, n_placements) == (3, 24) + + +def test_name_topics_fills_gaps_from_terms(monkeypatch): + monkeypatch.setattr( + "backend.claude_client.complete", + lambda *a, **k: json.dumps({"topics": [{"id": 0, "label": "Named", "description": "D."}]}), + ) + named = project.name_topics({ + 0: {"terms": ["a"], "examples": [], "size": 3}, + 1: {"terms": ["roofs", "albedo"], "examples": [], "size": 2}, + }) + assert named == {0: ("Named", "D."), 1: ("Roofs, albedo", None)} diff --git a/tests/test_quality_fixes.py b/tests/test_quality_fixes.py index bc03a7b..6914646 100644 --- a/tests/test_quality_fixes.py +++ b/tests/test_quality_fixes.py @@ -1,9 +1,7 @@ import pytest -from conftest import add_analysis, add_chunk +from conftest import prose -from backend.api.main import fetch_outliers from backend.config import settings -from backend.db import connect from backend.embedding import embed from backend.processing.dedup import chunk_and_dedup from backend.synthesis.rag import order_and_dedupe_by_article @@ -79,34 +77,15 @@ def test_rag_keeps_best_ranked_chunk_per_article(): assert [r["chunk_id"] for r in out] == ["a2#p0c1", "a1#p0c0"] -# ── outliers ───────────────────────────────────────────────────────────────── +# ── article-level dedup ────────────────────────────────────────────────────── -@pytest.fixture -def db(isolated): - add_analysis("an1") - add_chunk("an1", "a1", "a1#0", distance=9.0) - add_chunk("an1", "a1", "a1#1", distance=8.0) - add_chunk("an1", "a1", "a1#2", distance=7.0) - add_chunk("an1", "a2", "a2#0", distance=1.0) - add_chunk("an1", "a3", "a3#0", cluster_id=1, label="one", distance=5.0) - conn = connect() - yield conn - conn.close() +def test_syndicated_copy_with_different_intro_is_a_duplicate(): + from backend.processing.dedup import find_duplicate_articles - -def test_outliers_one_row_per_article(db): - rows = fetch_outliers(db, "an1") - assert [r["article_id"] for r in rows] == ["a1", "a3", "a2"] - assert rows[0]["chunk_id"] == "a1#0" - assert rows[0]["distance_from_centroid"] == 9.0 - - -def test_outliers_filtered_by_cluster(db): - rows = fetch_outliers(db, "an1", cluster_id=1) - assert [r["article_id"] for r in rows] == ["a3"] - - -def test_outliers_scoped_to_analysis(db): - add_analysis("an2") - add_chunk("an2", "b1", "b1#0", distance=99.0) - assert "b1" not in [r["article_id"] for r in fetch_outliers(db, "an1")] + story = prose(1) + articles = [ + {"id": "orig", "raw_text": story}, + {"id": "copy", "raw_text": "Reprinted with permission from the Wire Service. " + story}, + {"id": "other", "raw_text": prose(2)}, + ] + assert find_duplicate_articles(articles) == {"copy": "orig"} diff --git a/tests/test_topics.py b/tests/test_topics.py new file mode 100644 index 0000000..a2c2a2c --- /dev/null +++ b/tests/test_topics.py @@ -0,0 +1,116 @@ +import numpy as np +import pytest + +from backend import topics +from backend.topics import NOISE + + +def blobs(n_per=12, centers=4, dim=32, spread=0.05, seed=0): + """Well-separated groups of unit vectors.""" + rng = np.random.default_rng(seed) + C = topics.normalize(rng.normal(size=(centers, dim))) + X = np.concatenate([C[c] + rng.normal(scale=spread, size=(n_per, dim)) for c in range(centers)]) + truth = np.repeat(np.arange(centers), n_per) + return topics.normalize(X), truth + + +def same_partition(a, b): + """True if two labelings group the items identically (ids may differ).""" + pairs = {(x, y) for x, y in zip(a.tolist(), b.tolist(), strict=True)} + return len({x for x, _ in pairs}) == len(pairs) == len({y for _, y in pairs}) + + +def test_article_vectors_average_and_normalise(): + v = np.array([[2.0, 0], [0, 2.0], [3.0, 0]]) + ids, X = topics.article_vectors(v, ["a", "a", "b"]) + assert ids == ["a", "b"] + np.testing.assert_allclose(X[0], [np.sqrt(0.5), np.sqrt(0.5)]) + np.testing.assert_allclose(np.linalg.norm(X, axis=1), 1) + + +def test_model_recovers_separated_topics(): + X, truth = blobs() + labels, coords, dist = topics.model_topics(X) + assert set(labels.tolist()) == {0, 1, 2, 3} + assert same_partition(labels, truth) + assert coords.shape == (len(X), 2) + assert np.all(dist >= -1e-6) and np.all(dist < 0.2) + + +def test_relabel_orders_by_size_and_keeps_noise(): + out = topics.relabel(np.array([5, 5, 2, 2, 2, NOISE, 9])) + assert out.tolist() == [1, 1, 0, 0, 0, NOISE, 2] + + +def test_noise_joins_topic_only_when_typical(): + X, truth = blobs(centers=2) + labels = truth.copy() + # The most typical member of topic 0, wrongly marked noise. + typical = int(np.argmax(X[truth == 0] @ topics.centroids(X, truth)[0])) + labels[typical] = NOISE + stray = topics.normalize(np.random.default_rng(1).normal(size=(1, X.shape[1]))) + X = np.vstack([X, stray]) + labels = np.append(labels, NOISE) + + out = topics.assign_noise(X, labels) + assert out[typical] == 0 # rejoined + assert out[-1] == NOISE # unlike anything: stays unclustered + + +def test_cap_merges_most_similar_topics(): + X, truth = blobs(centers=5, n_per=6) + # Make topics 3 and 4 nearly identical so they're the pair to merge. + X[truth == 4] = X[truth == 3][: (truth == 4).sum()] + 1e-3 + X = topics.normalize(X) + out = topics.cap_topics(X, truth, max_topics=4) + assert len(set(out.tolist())) == 4 + assert len(set(out[(truth == 3) | (truth == 4)].tolist())) == 1 + + +def test_tiny_corpus_is_one_topic(): + X, _ = blobs(n_per=1, centers=4) + assert topics.find_topics(X).tolist() == [0, 0, 0, 0] + + +def test_topic_terms_prefer_distinctive_words(): + texts = [ + "cool roofs reflect sunlight; white roofs lower city heat", + "green roofs and cool roofs cut rooftop heat in the city", + "heat equity: redlining and gentrification shape city heat exposure", + "gentrification and redlining concentrate heat in poorer city blocks", + ] + labels = np.array([0, 0, 1, 1]) + terms = topics.topic_terms(texts, labels, top_n=4) + zero = [t for t, _ in terms[0]] + one = [t for t, _ in terms[1]] + assert any("roofs" in t for t in zero) + assert any("gentrification" in t or "redlining" in t for t in one) + assert "city" not in zero + one # in every topic: no signal + assert all("http" not in t for t in zero + one) + + +def test_topic_terms_ignore_noise_and_urls(): + terms = topics.topic_terms( + ["see https://example.com/page about tariffs and tariffs", "unrelated words here"], + np.array([0, NOISE]), + ) + assert list(terms) == [0] + assert all("example" not in t for t, _ in terms[0]) + + +@pytest.mark.parametrize("k", [1, 3]) +def test_representatives_are_closest_members(k): + X, truth = blobs(centers=2) + reps = topics.representatives(X, truth, k=k) + assert set(reps) == {0, 1} + for t, idx in reps.items(): + assert len(idx) == k + assert all(truth[i] == t for i in idx) + + +def test_merge_indistinct_joins_arbitrary_splits_only(): + X, truth = blobs(centers=3) + split = truth.copy() + split[(truth == 0) & (np.arange(len(truth)) % 2 == 0)] = 7 # cut topic 0 in half + out = topics.merge_indistinct(X, split) + assert same_partition(out, truth) From 0ebf2aff8b228885dec8da2aa5c9d799c6a7bdd9 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Mon, 12 Oct 2026 18:36:36 -0400 Subject: [PATCH 08/16] Show topics from the server: names, summaries, key terms, unclustered - Load /articles and /clusters; drop client-side chunk aggregation and TF-IDF (corpus payload no longer carries chunk text) - Map draws one dot per article with contours per topic; unclustered articles shown in grey without a centre label; more padding for contours - Key terms page shows each topic's summary and c-TF-IDF terms - Cluster key shows the selected topic's summary Co-Authored-By: Claude Opus 5.5 --- frontend/openapi.json | 256 ++--- frontend/src/lib/api.ts | 7 +- frontend/src/lib/api/gen/client.gen.ts | 4 +- frontend/src/lib/api/gen/client/client.gen.ts | 526 +++++----- frontend/src/lib/api/gen/client/index.ts | 26 +- frontend/src/lib/api/gen/client/types.gen.ts | 307 +++--- frontend/src/lib/api/gen/client/utils.gen.ts | 484 ++++----- frontend/src/lib/api/gen/core/auth.gen.ts | 68 +- .../lib/api/gen/core/bodySerializer.gen.ts | 100 +- frontend/src/lib/api/gen/core/params.gen.ts | 298 +++--- .../lib/api/gen/core/pathSerializer.gen.ts | 256 ++--- .../api/gen/core/queryKeySerializer.gen.ts | 141 +-- .../lib/api/gen/core/serverSentEvents.gen.ts | 454 ++++---- frontend/src/lib/api/gen/core/types.gen.ts | 162 +-- frontend/src/lib/api/gen/core/utils.gen.ts | 226 ++-- frontend/src/lib/api/gen/index.ts | 97 +- frontend/src/lib/api/gen/sdk.gen.ts | 216 +--- frontend/src/lib/api/gen/types.gen.ts | 993 ++++++++---------- frontend/src/lib/components/ClusterKey.svelte | 13 +- frontend/src/lib/components/SurveyMap.svelte | 19 +- frontend/src/lib/corpus.spec.ts | 132 +-- frontend/src/lib/corpus.ts | 185 +--- frontend/src/lib/workspace.svelte.ts | 11 +- .../src/routes/analysis/[id]/+layout.svelte | 2 +- frontend/src/routes/analysis/[id]/+layout.ts | 8 +- .../routes/analysis/[id]/terms/+page.svelte | 29 +- 26 files changed, 2259 insertions(+), 2761 deletions(-) diff --git a/frontend/openapi.json b/frontend/openapi.json index 5ef75d0..a02ad86 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -289,10 +289,11 @@ } } }, - "/api/analyses/{analysis_id}/corpus": { + "/api/analyses/{analysis_id}/articles": { "get": { - "summary": "Get Corpus", - "operationId": "get_corpus", + "summary": "List Articles", + "description": "Every article on the map, with its position, topic and outlier score.", + "operationId": "list_articles", "parameters": [ { "name": "analysis_id", @@ -312,9 +313,9 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/CorpusPoint" + "$ref": "#/components/schemas/MapArticle" }, - "title": "Response Get Corpus" + "title": "Response List Articles" } } } @@ -335,6 +336,7 @@ "/api/analyses/{analysis_id}/clusters": { "get": { "summary": "Get Clusters", + "description": "Topics, largest first, with their key terms. Unclustered articles have none.", "operationId": "get_clusters", "parameters": [ { @@ -375,65 +377,6 @@ } } }, - "/api/analyses/{analysis_id}/outliers": { - "get": { - "summary": "Get Outliers", - "operationId": "get_outliers", - "parameters": [ - { - "name": "analysis_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Analysis Id" - } - }, - { - "name": "cluster_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Cluster Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Outlier" - }, - "title": "Response Get Outliers" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, "/api/analyses/{analysis_id}/articles/{article_id}": { "get": { "summary": "Get Article", @@ -901,7 +844,11 @@ "type": "integer", "title": "Cluster Id" }, - "cluster_label": { + "label": { + "type": "string", + "title": "Label" + }, + "description": { "anyOf": [ { "type": "string" @@ -910,42 +857,49 @@ "type": "null" } ], - "title": "Cluster Label" + "title": "Description" }, "article_count": { "type": "integer", "title": "Article Count" + }, + "terms": { + "items": { + "$ref": "#/components/schemas/Term" + }, + "type": "array", + "title": "Terms" } }, "type": "object", "required": [ "cluster_id", - "cluster_label", - "article_count" + "label", + "description", + "article_count", + "terms" ], "title": "Cluster" }, - "CorpusPoint": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "MapArticle": { "properties": { - "chunk_id": { - "type": "string", - "title": "Chunk Id" - }, "article_id": { "type": "string", "title": "Article Id" }, - "child_text": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Child Text" - }, "url": { "type": "string", "title": "Url" @@ -983,6 +937,17 @@ ], "title": "Publish Date" }, + "word_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Word Count" + }, "x": { "type": "number", "title": "X" @@ -995,139 +960,44 @@ "type": "integer", "title": "Cluster Id" }, - "cluster_label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Cluster Label" - }, - "distance_from_centroid": { + "distance": { "type": "number", - "title": "Distance From Centroid" - }, - "word_count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Word Count" + "title": "Distance" } }, "type": "object", "required": [ - "chunk_id", "article_id", - "child_text", "url", "title", "domain", "publish_date", + "word_count", "x", "y", "cluster_id", - "cluster_label", - "distance_from_centroid", - "word_count" + "distance" ], - "title": "CorpusPoint" + "title": "MapArticle", + "description": "An article as placed on the map." }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "Outlier": { + "Term": { "properties": { - "chunk_id": { + "term": { "type": "string", - "title": "Chunk Id" - }, - "cluster_id": { - "type": "integer", - "title": "Cluster Id" - }, - "cluster_label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Cluster Label" + "title": "Term" }, - "distance_from_centroid": { + "weight": { "type": "number", - "title": "Distance From Centroid" - }, - "article_id": { - "type": "string", - "title": "Article Id" - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Title" - }, - "domain": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Domain" - }, - "publish_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Publish Date" + "title": "Weight" } }, "type": "object", "required": [ - "chunk_id", - "cluster_id", - "cluster_label", - "distance_from_centroid", - "article_id", - "title", - "domain", - "publish_date" + "term", + "weight" ], - "title": "Outlier" + "title": "Term" }, "ValidationError": { "properties": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1e0a013..fe084c9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -13,7 +13,9 @@ export type { Article, ChatResponse, Citation, - CorpusPoint + Cluster, + MapArticle, + Term } from './api/gen/types.gen'; // Same origin in production (FastAPI serves the app); Vite proxies /api in dev. @@ -68,7 +70,8 @@ export const api = { getAnalysis: (id: string, fetch?: Fetch) => unwrap(sdk.getAnalysis({ ...path(id), fetch })), deleteAnalysis: (id: string) => unwrap(sdk.deleteAnalysis(path(id))), cancelAnalysis: (id: string) => unwrap(sdk.cancelAnalysis(path(id))), - getCorpus: (id: string, fetch?: Fetch) => unwrap(sdk.getCorpus({ ...path(id), fetch })), + listArticles: (id: string, fetch?: Fetch) => unwrap(sdk.listArticles({ ...path(id), fetch })), + getClusters: (id: string, fetch?: Fetch) => unwrap(sdk.getClusters({ ...path(id), fetch })), getArticle: (id: string, articleId: string) => unwrap(sdk.getArticle({ path: { analysis_id: id, article_id: articleId } })), chat: (id: string, query: string) => unwrap(sdk.chat({ ...path(id), body: { query } })) diff --git a/frontend/src/lib/api/gen/client.gen.ts b/frontend/src/lib/api/gen/client.gen.ts index eee936b..47828b7 100644 --- a/frontend/src/lib/api/gen/client.gen.ts +++ b/frontend/src/lib/api/gen/client.gen.ts @@ -11,8 +11,6 @@ import type { ClientOptions as ClientOptions2 } from './types.gen'; * `setConfig()`. This is useful for example if you're using Next.js * to ensure your client always has the correct values. */ -export type CreateClientConfig = ( - override?: Config -) => Config & T>; +export type CreateClientConfig = (override?: Config) => Config & T>; export const client: Client = createClient(createConfig()); diff --git a/frontend/src/lib/api/gen/client/client.gen.ts b/frontend/src/lib/api/gen/client/client.gen.ts index 5243298..fc3f037 100644 --- a/frontend/src/lib/api/gen/client/client.gen.ts +++ b/frontend/src/lib/api/gen/client/client.gen.ts @@ -5,273 +5,273 @@ import type { HttpMethod } from '../core/types.gen'; import { getValidRequestBody } from '../core/utils.gen'; import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; import { - buildUrl, - createConfig, - createInterceptors, - getParseAs, - mergeConfigs, - mergeHeaders, - setAuthParams + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, } from './utils.gen'; type ReqInit = Omit & { - body?: any; - headers: ReturnType; + body?: any; + headers: ReturnType; }; export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config); - - const getConfig = (): Config => ({ ..._config }); - - const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config); - return getConfig(); - }; - - const interceptors = createInterceptors(); - - const beforeRequest = async < - TData = unknown, - TResponseStyle extends 'data' | 'fields' = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string - >( - options: RequestOptions - ) => { - const opts = { - ..._config, - ...options, - fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, - headers: mergeHeaders(_config.headers, options.headers), - serializedBody: undefined as string | undefined - }; - - if (opts.security) { - await setAuthParams(opts); - } - - if (opts.requestValidator) { - await opts.requestValidator(opts); - } - - if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; - } - - // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === '') { - opts.headers.delete('Content-Type'); - } - - const resolvedOpts = opts as typeof opts & - ResolvedRequestOptions; - const url = buildUrl(resolvedOpts); - - return { opts: resolvedOpts, url }; - }; - - const request: Client['request'] = async (options) => { - const throwOnError = options.throwOnError ?? _config.throwOnError; - const responseStyle = options.responseStyle ?? _config.responseStyle; - - let request: Request | undefined; - let response: Response | undefined; - - try { - const { opts, url } = await beforeRequest(options); - const requestInit: ReqInit = { - redirect: 'follow', - ...opts, - body: getValidRequestBody(opts) - }; - - request = new Request(url, requestInit); - - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch!; - - response = await _fetch(request); - - for (const fn of interceptors.response.fns) { - if (fn) { - response = await fn(response, request, opts); - } - } - - const result = { - request, - response - }; - - if (response.ok) { - const parseAs = - (opts.parseAs === 'auto' - ? getParseAs(response.headers.get('Content-Type')) - : opts.parseAs) ?? 'json'; - - if (response.status === 204 || response.headers.get('Content-Length') === '0') { - let emptyData: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'text': - emptyData = await response[parseAs](); - break; - case 'formData': - emptyData = new FormData(); - break; - case 'stream': - emptyData = response.body; - break; - case 'json': - default: - emptyData = {}; - break; - } - return opts.responseStyle === 'data' - ? emptyData - : { - data: emptyData, - ...result - }; - } - - let data: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'formData': - case 'text': - data = await response[parseAs](); - break; - case 'json': { - // Some servers return 200 with no Content-Length and empty body. - // response.json() would throw; read as text and parse if non-empty. - const text = await response.text(); - data = text ? JSON.parse(text) : {}; - break; - } - case 'stream': - return opts.responseStyle === 'data' - ? response.body - : { - data: response.body, - ...result - }; - } - - if (parseAs === 'json') { - if (opts.responseValidator) { - await opts.responseValidator(data); - } - - if (opts.responseTransformer) { - data = await opts.responseTransformer(data); - } - } - - return opts.responseStyle === 'data' - ? data - : { - data, - ...result - }; - } - - const textError = await response.text(); - let jsonError: unknown; - - try { - jsonError = JSON.parse(textError); - } catch { - // noop - } - - throw jsonError ?? textError; - } catch (error) { - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); - } - } - - finalError = finalError || {}; - - if (throwOnError) { - throw finalError; - } - - // TODO: we probably want to return error and improve types - return responseStyle === 'data' - ? undefined - : { - error: finalError, - request, - response - }; - } - }; - - const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => - request({ ...options, method }); - - const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options); - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - method, - onRequest: async (url, init) => { - let request = new Request(url, init); - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - return request; - }, - serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, - url - }); - }; - - const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); - - return { - buildUrl: _buildUrl, - connect: makeMethodFn('CONNECT'), - delete: makeMethodFn('DELETE'), - get: makeMethodFn('GET'), - getConfig, - head: makeMethodFn('HEAD'), - interceptors, - options: makeMethodFn('OPTIONS'), - patch: makeMethodFn('PATCH'), - post: makeMethodFn('POST'), - put: makeMethodFn('PUT'), - request, - setConfig, - sse: { - connect: makeSseFn('CONNECT'), - delete: makeSseFn('DELETE'), - get: makeSseFn('GET'), - head: makeSseFn('HEAD'), - options: makeSseFn('OPTIONS'), - patch: makeSseFn('PATCH'), - post: makeSseFn('POST'), - put: makeSseFn('PUT'), - trace: makeSseFn('TRACE') - }, - trace: makeMethodFn('TRACE') - } as Client; + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined, + }; + + if (opts.security) { + await setAuthParams(opts); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client['request'] = async (options) => { + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; + + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError; + } catch (error) { + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); + } + } + + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response, + }; + } + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; }; diff --git a/frontend/src/lib/api/gen/client/index.ts b/frontend/src/lib/api/gen/client/index.ts index 2ece92c..8c69331 100644 --- a/frontend/src/lib/api/gen/client/index.ts +++ b/frontend/src/lib/api/gen/client/index.ts @@ -3,9 +3,9 @@ export type { Auth } from '../core/auth.gen'; export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; export { - formDataBodySerializer, - jsonBodySerializer, - urlSearchParamsBodySerializer + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, } from '../core/bodySerializer.gen'; export { buildClientParams } from '../core/params.gen'; export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; @@ -13,15 +13,15 @@ export type { ServerSentEventsResult } from '../core/serverSentEvents.gen'; export type { ClientMeta } from '../core/types.gen'; export { createClient } from './client.gen'; export type { - Client, - ClientOptions, - Config, - CreateClientConfig, - Options, - RequestOptions, - RequestResult, - ResolvedRequestOptions, - ResponseStyle, - TDataShape + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, } from './types.gen'; export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/frontend/src/lib/api/gen/client/types.gen.ts b/frontend/src/lib/api/gen/client/types.gen.ts index 9331d25..193646c 100644 --- a/frontend/src/lib/api/gen/client/types.gen.ts +++ b/frontend/src/lib/api/gen/client/types.gen.ts @@ -1,184 +1,187 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Auth } from '../core/auth.gen'; -import type { ServerSentEventsOptions, ServerSentEventsResult } from '../core/serverSentEvents.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; import type { Middleware } from './utils.gen'; export type ResponseStyle = 'data' | 'fields'; export interface Config - extends Omit, CoreConfig { - /** - * Base URL for all requests made by this client. - */ - baseUrl?: T['baseUrl']; - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Please don't use the Fetch client for Next.js applications. The `next` - * options won't have any effect. - * - * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. - */ - next?: never; - /** - * Return the response data parsed in a specified format. By default, `auto` - * will infer the appropriate method from the `Content-Type` response header. - * You can override this behavior with any of the {@link Body} methods. - * Select `stream` if you don't want to parse response data at all. - * - * @default 'auto' - */ - parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; - /** - * Should we return only data or multiple fields (data, error, response, etc.)? - * - * @default 'fields' - */ - responseStyle?: ResponseStyle; - /** - * Throw an error instead of returning it in the response? - * - * @default false - */ - throwOnError?: T['throwOnError']; + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; } export interface RequestOptions< - TData = unknown, - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, > - extends - Config<{ - responseStyle: TResponseStyle; - throwOnError: ThrowOnError; - }>, - Pick< - ServerSentEventsOptions, - | 'onRequest' - | 'onSseError' - | 'onSseEvent' - | 'sseDefaultRetryDelay' - | 'sseMaxRetryAttempts' - | 'sseMaxRetryDelay' - > { - /** - * Any body that you want to add to your request. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} - */ - body?: unknown; - path?: Record; - query?: Record; - /** - * Security mechanism(s) to use for the request. - */ - security?: ReadonlyArray; - url: Url; + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; } export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, > extends RequestOptions { - headers: Headers; - serializedBody?: string; + headers: Headers; + serializedBody?: string; } export type RequestResult< - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = 'fields' + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', > = ThrowOnError extends true - ? Promise< - TResponseStyle extends 'data' - ? TData extends Record - ? TData[keyof TData] - : TData - : { - data: TData extends Record ? TData[keyof TData] : TData; - request: Request; - response: Response; - } - > - : Promise< - TResponseStyle extends 'data' - ? (TData extends Record ? TData[keyof TData] : TData) | undefined - : ( - | { - data: TData extends Record ? TData[keyof TData] : TData; - error: undefined; - } - | { - data: undefined; - error: TError extends Record ? TError[keyof TError] : TError; - } - ) & { - /** request may be undefined, because error may be from building the request object itself */ - request?: Request; - /** response may be undefined, because error may be from building the request object itself or from a network error */ - response?: Response; - } - >; + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; + } + >; export interface ClientOptions { - baseUrl?: string; - responseStyle?: ResponseStyle; - throwOnError?: boolean; + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; } type MethodFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields' + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, 'method'> + options: Omit, 'method'>, ) => RequestResult; type SseFn = < - TData = unknown, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields' + TData = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, 'method'> + options: Omit, 'method'>, ) => Promise>; type RequestFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields' + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, 'method'> & - Pick>, 'method'> + options: Omit, 'method'> & + Pick>, 'method'>, ) => RequestResult; type BuildUrlFn = < - TData extends { - body?: unknown; - path?: Record; - query?: Record; - url: string; - } + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, >( - options: TData & Options + options: TData & Options, ) => string; export type Client = CoreClient & { - interceptors: Middleware; + interceptors: Middleware; }; /** @@ -190,26 +193,26 @@ export type Client = CoreClient * to ensure your client always has the correct values. */ export type CreateClientConfig = ( - override?: Config + override?: Config, ) => Config & T>; export interface TDataShape { - body?: unknown; - headers?: unknown; - path?: unknown; - query?: unknown; - url: string; + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; } type OmitKeys = Pick>; export type Options< - TData extends TDataShape = TDataShape, - ThrowOnError extends boolean = boolean, - TResponse = unknown, - TResponseStyle extends ResponseStyle = 'fields' + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', > = OmitKeys< - RequestOptions, - 'body' | 'path' | 'query' | 'url' + RequestOptions, + 'body' | 'path' | 'query' | 'url' > & - ([TData] extends [never] ? unknown : Omit); + ([TData] extends [never] ? unknown : Omit); diff --git a/frontend/src/lib/api/gen/client/utils.gen.ts b/frontend/src/lib/api/gen/client/utils.gen.ts index d6bc9ff..d4a7284 100644 --- a/frontend/src/lib/api/gen/client/utils.gen.ts +++ b/frontend/src/lib/api/gen/client/utils.gen.ts @@ -4,313 +4,313 @@ import { getAuthToken } from '../core/auth.gen'; import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; import { jsonBodySerializer } from '../core/bodySerializer.gen'; import { - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, } from '../core/pathSerializer.gen'; import { getUrl } from '../core/utils.gen'; import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; export const createQuerySerializer = ({ - parameters = {}, - ...args + parameters = {}, + ...args }: QuerySerializerOptions = {}): ((queryParams: T) => string) => { - const querySerializer = (queryParams: T): string => { - const search: string[] = []; - if (queryParams && typeof queryParams === 'object') { - for (const name in queryParams) { - const value = queryParams[name]; - - if (value === undefined || value === null) { - continue; - } - - const options = parameters[name] || args; - - if (Array.isArray(value)) { - const serializedArray = serializeArrayParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'form', - value, - ...options.array - }); - if (serializedArray) search.push(serializedArray); - } else if (typeof value === 'object') { - const serializedObject = serializeObjectParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'deepObject', - value: value as Record, - ...options.object - }); - if (serializedObject) search.push(serializedObject); - } else { - const serializedPrimitive = serializePrimitiveParam({ - allowReserved: options.allowReserved, - name, - value: value as string - }); - if (serializedPrimitive) search.push(serializedPrimitive); - } - } - } - return search.join('&'); - }; - return querySerializer; + const querySerializer = (queryParams: T): string => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; }; /** * Infers parseAs value from provided Content-Type header. */ export const getParseAs = (contentType: string | null): Exclude => { - if (!contentType) { - // If no Content-Type header is provided, the best we can do is return the raw response body, - // which is effectively the same as the 'stream' option. - return 'stream'; - } + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } - const cleanContent = contentType.split(';')[0]?.trim(); + const cleanContent = contentType.split(';')[0]?.trim(); - if (!cleanContent) { - return; - } + if (!cleanContent) { + return; + } - if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { - return 'json'; - } + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } - if (cleanContent === 'multipart/form-data') { - return 'formData'; - } + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } - if ( - ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) - ) { - return 'blob'; - } + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } - if (cleanContent.startsWith('text/')) { - return 'text'; - } + if (cleanContent.startsWith('text/')) { + return 'text'; + } - return; + return; }; const checkForExistence = ( - options: Pick & { - headers: Headers; - }, - name?: string + options: Pick & { + headers: Headers; + }, + name?: string, ): boolean => { - if (!name) { - return false; - } - if ( - options.headers.has(name) || - options.query?.[name] || - options.headers.get('Cookie')?.includes(`${name}=`) - ) { - return true; - } - return false; + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; }; export async function setAuthParams( - options: Pick & { - headers: Headers; - } + options: Pick & { + headers: Headers; + }, ): Promise { - for (const auth of options.security ?? []) { - if (checkForExistence(options, auth.name)) { - continue; - } - - const token = await getAuthToken(auth, options.auth); - - if (!token) { - continue; - } - - const name = auth.name ?? 'Authorization'; - - switch (auth.in) { - case 'query': - if (!options.query) { - options.query = {}; - } - options.query[name] = token; - break; - case 'cookie': - options.headers.append('Cookie', `${name}=${token}`); - break; - case 'header': - default: - options.headers.set(name, token); - break; - } - } + for (const auth of options.security ?? []) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } } export const buildUrl: Client['buildUrl'] = (options) => - getUrl({ - baseUrl: options.baseUrl as string, - path: options.path, - query: options.query, - querySerializer: - typeof options.querySerializer === 'function' - ? options.querySerializer - : createQuerySerializer(options.querySerializer), - url: options.url - }); + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b }; - if (config.baseUrl?.endsWith('/')) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); - } - config.headers = mergeHeaders(a.headers, b.headers); - return config; + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; }; const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = []; - headers.forEach((value, key) => { - entries.push([key, value]); - }); - return entries; + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; }; export const mergeHeaders = ( - ...headers: Array['headers'] | undefined> + ...headers: Array['headers'] | undefined> ): Headers => { - const mergedHeaders = new Headers(); - for (const header of headers) { - if (!header) { - continue; - } - - const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); - - for (const [key, value] of iterator) { - if (value === null) { - mergedHeaders.delete(key); - } else if (Array.isArray(value)) { - for (const v of value) { - mergedHeaders.append(key, v as string); - } - } else if (value !== undefined) { - // assume object headers are meant to be JSON stringified, i.e., their - // content value in OpenAPI specification is 'application/json' - mergedHeaders.set( - key, - typeof value === 'object' ? JSON.stringify(value) : (value as string) - ); - } - } - } - return mergedHeaders; + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; }; type ErrInterceptor = ( - error: Err, - /** response may be undefined due to a network error where no response object is produced */ - response: Res | undefined, - /** request may be undefined, because error may be from building the request object itself */ - request: Req | undefined, - options: Options + error: Err, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, + options: Options, ) => Err | Promise; type ReqInterceptor = (request: Req, options: Options) => Req | Promise; type ResInterceptor = ( - response: Res, - request: Req, - options: Options + response: Res, + request: Req, + options: Options, ) => Res | Promise; class Interceptors { - fns: Array = []; - - clear(): void { - this.fns = []; - } - - eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = null; - } - } - - exists(id: number | Interceptor): boolean { - const index = this.getInterceptorIndex(id); - return Boolean(this.fns[index]); - } - - getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === 'number') { - return this.fns[id] ? id : -1; - } - return this.fns.indexOf(id); - } - - update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = fn; - return id; - } - return false; - } - - use(fn: Interceptor): number { - this.fns.push(fn); - return this.fns.length - 1; - } + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } } export interface Middleware { - error: Interceptors>; - request: Interceptors>; - response: Interceptors>; + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; } export const createInterceptors = (): Middleware< - Req, - Res, - Err, - Options + Req, + Res, + Err, + Options > => ({ - error: new Interceptors>(), - request: new Interceptors>(), - response: new Interceptors>() + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), }); const defaultQuerySerializer = createQuerySerializer({ - allowReserved: false, - array: { - explode: true, - style: 'form' - }, - object: { - explode: true, - style: 'deepObject' - } + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, }); const defaultHeaders = { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }; export const createConfig = ( - override: Config & T> = {} + override: Config & T> = {}, ): Config & T> => ({ - ...jsonBodySerializer, - headers: defaultHeaders, - parseAs: 'auto', - querySerializer: defaultQuerySerializer, - ...override + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, }); diff --git a/frontend/src/lib/api/gen/core/auth.gen.ts b/frontend/src/lib/api/gen/core/auth.gen.ts index 29b1c54..c663664 100644 --- a/frontend/src/lib/api/gen/core/auth.gen.ts +++ b/frontend/src/lib/api/gen/core/auth.gen.ts @@ -3,46 +3,46 @@ export type AuthToken = string | undefined; export interface Auth { - /** - * Which part of the request do we use to send the auth? - * - * @default 'header' - */ - in?: 'header' | 'query' | 'cookie'; - /** - * A unique identifier for the security scheme. - * - * Defined only when there are multiple security schemes whose `Auth` - * shape would otherwise be identical. - */ - key?: string; - /** - * Header or query parameter name. - * - * @default 'Authorization' - */ - name?: string; - scheme?: 'basic' | 'bearer'; - type: 'apiKey' | 'http'; + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; } export const getAuthToken = async ( - auth: Auth, - callback: ((auth: Auth) => Promise | AuthToken) | AuthToken + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, ): Promise => { - const token = typeof callback === 'function' ? await callback(auth) : callback; + const token = typeof callback === 'function' ? await callback(auth) : callback; - if (!token) { - return; - } + if (!token) { + return; + } - if (auth.scheme === 'bearer') { - return `Bearer ${token}`; - } + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } - if (auth.scheme === 'basic') { - return `Basic ${btoa(token)}`; - } + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } - return token; + return token; }; diff --git a/frontend/src/lib/api/gen/core/bodySerializer.gen.ts b/frontend/src/lib/api/gen/core/bodySerializer.gen.ts index 7cbe186..67daca6 100644 --- a/frontend/src/lib/api/gen/core/bodySerializer.gen.ts +++ b/frontend/src/lib/api/gen/core/bodySerializer.gen.ts @@ -7,76 +7,76 @@ export type QuerySerializer = (query: Record) => string; export type BodySerializer = (body: unknown) => unknown; type QuerySerializerOptionsObject = { - allowReserved?: boolean; - array?: Partial>; - object?: Partial>; + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; }; export type QuerySerializerOptions = QuerySerializerOptionsObject & { - /** - * Per-parameter serialization overrides. When provided, these settings - * override the global array/object settings for specific parameter names. - */ - parameters?: Record; + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; }; const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { - if (typeof value === 'string' || value instanceof Blob) { - data.append(key, value); - } else if (value instanceof Date) { - data.append(key, value.toISOString()); - } else { - data.append(key, JSON.stringify(value)); - } + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } }; const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { - if (typeof value === 'string') { - data.append(key, value); - } else { - data.append(key, JSON.stringify(value)); - } + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } }; export const formDataBodySerializer = { - bodySerializer: (body: unknown): FormData => { - const data = new FormData(); + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); - Object.entries(body as Record).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)); - } else { - serializeFormDataPair(data, key, value); - } - }); + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); - return data; - } + return data; + }, }; export const jsonBodySerializer = { - bodySerializer: (body: unknown): string => - JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)) + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), }; export const urlSearchParamsBodySerializer = { - bodySerializer: (body: unknown): string => { - const data = new URLSearchParams(); + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); - Object.entries(body as Record).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); - } else { - serializeUrlSearchParamsPair(data, key, value); - } - }); + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); - return data.toString(); - } + return data.toString(); + }, }; diff --git a/frontend/src/lib/api/gen/core/params.gen.ts b/frontend/src/lib/api/gen/core/params.gen.ts index 514c217..5e8908f 100644 --- a/frontend/src/lib/api/gen/core/params.gen.ts +++ b/frontend/src/lib/api/gen/core/params.gen.ts @@ -3,176 +3,176 @@ type Slot = 'body' | 'headers' | 'path' | 'query'; export type Field = - | { - in: Exclude; - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If omitted, we use the same value as `key`. - */ - map?: string; - } - | { - in: Extract; - /** - * Key isn't required for bodies. - */ - key?: string; - map?: string; - } - | { - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If `in` is omitted, `map` aliases `key` to the transport layer. - */ - map: Slot; - }; + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; export interface Fields { - allowExtra?: Partial>; - args?: ReadonlyArray; + allowExtra?: Partial>; + args?: ReadonlyArray; } export type FieldsConfig = ReadonlyArray; const extraPrefixesMap: Record = { - $body_: 'body', - $headers_: 'headers', - $path_: 'path', - $query_: 'query' + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', }; const extraPrefixes = Object.entries(extraPrefixesMap); type KeyMap = Map< - string, - | { - in: Slot; - map?: string; - } - | { - in?: never; - map: Slot; - } + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } >; function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { - if (!map) { - map = new Map(); - } - - for (const config of fields) { - if ('in' in config) { - if (config.key) { - map.set(config.key, { - in: config.in, - map: config.map - }); - } - } else if ('key' in config) { - map.set(config.key, { - map: config.map - }); - } else if (config.args) { - buildKeyMap(config.args, map); - } - } - - return map; + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; } interface Params { - body?: unknown; - headers: Record; - path: Record; - query: Record; + body?: unknown; + headers: Record; + path: Record; + query: Record; } function stripEmptySlots(params: Params): void { - for (const [slot, value] of Object.entries(params)) { - if (slot === 'body') continue; - if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { - delete params[slot as Slot]; - } - } + for (const [slot, value] of Object.entries(params)) { + if (slot === 'body') continue; + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } } export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { - const params: Params = { - headers: Object.create(null), - path: Object.create(null), - query: Object.create(null) - }; - - const map = buildKeyMap(fields); - - function writeSlot(slot: Slot, key: string, value: unknown): void { - let record = params[slot] as Record | undefined; - if (record === undefined) { - record = Object.create(null) as Record; - params[slot] = record; - } - record[key] = value; - } - - let config: FieldsConfig[number] | undefined; - - for (const [index, arg] of args.entries()) { - if (fields[index]) { - config = fields[index]; - } - - if (!config) { - continue; - } - - if ('in' in config) { - if (config.key) { - const field = map.get(config.key)!; - const name = field.map || config.key; - if (field.in) { - writeSlot(field.in, name, arg); - } - } else { - params.body = arg; - } - } else { - for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key); - - if (field) { - if (field.in) { - const name = field.map || key; - writeSlot(field.in, name, value); - } else { - params[field.map] = value; - } - } else { - const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); - - if (extra) { - const [prefix, slot] = extra; - writeSlot(slot, key.slice(prefix.length), value); - } else if ('allowExtra' in config && config.allowExtra) { - for (const [slot, allowed] of Object.entries(config.allowExtra)) { - if (allowed) { - writeSlot(slot as Slot, key, value); - break; - } - } - } - } - } - } - } - - stripEmptySlots(params); - - return params; + const params: Params = { + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null), + }; + + const map = buildKeyMap(fields); + + function writeSlot(slot: Slot, key: string, value: unknown): void { + let record = params[slot] as Record | undefined; + if (record === undefined) { + record = Object.create(null) as Record; + params[slot] = record; + } + record[key] = value; + } + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + writeSlot(field.in, name, arg); + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + writeSlot(field.in, name, value); + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + writeSlot(slot, key.slice(prefix.length), value); + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + writeSlot(slot as Slot, key, value); + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; } diff --git a/frontend/src/lib/api/gen/core/pathSerializer.gen.ts b/frontend/src/lib/api/gen/core/pathSerializer.gen.ts index cb7334e..fab1ed4 100644 --- a/frontend/src/lib/api/gen/core/pathSerializer.gen.ts +++ b/frontend/src/lib/api/gen/core/pathSerializer.gen.ts @@ -3,16 +3,16 @@ interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} interface SerializePrimitiveOptions { - allowReserved?: boolean; - name: string; + allowReserved?: boolean; + name: string; } export interface SerializerOptions { - /** - * @default true - */ - explode: boolean; - style: T; + /** + * @default true + */ + explode: boolean; + style: T; } export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; @@ -22,150 +22,150 @@ export type ObjectStyle = 'form' | 'deepObject'; type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string; + value: string; } export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } }; export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { - switch (style) { - case 'form': - return ','; - case 'pipeDelimited': - return '|'; - case 'spaceDelimited': - return '%20'; - default: - return ','; - } + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } }; export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } }; export const serializeArrayParam = ({ - allowReserved, - explode, - name, - style, - value + allowReserved, + explode, + name, + style, + value, }: SerializeOptions & { - value: unknown[]; + value: unknown[]; }): string => { - if (!explode) { - const joinedValues = ( - allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) - ).join(separatorArrayNoExplode(style)); - switch (style) { - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - case 'simple': - return joinedValues; - default: - return `${name}=${joinedValues}`; - } - } - - const separator = separatorArrayExplode(style); - const joinedValues = value - .map((v) => { - if (style === 'label' || style === 'simple') { - return allowReserved ? v : encodeURIComponent(v as string); - } - - return serializePrimitiveParam({ - allowReserved, - name, - value: v as string - }); - }) - .join(separator); - return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; }; export const serializePrimitiveParam = ({ - allowReserved, - name, - value + allowReserved, + name, + value, }: SerializePrimitiveParam): string => { - if (value === undefined || value === null) { - return ''; - } + if (value === undefined || value === null) { + return ''; + } - if (typeof value === 'object') { - throw new Error( - 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.' - ); - } + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } - return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; }; export const serializeObjectParam = ({ - allowReserved, - explode, - name, - style, - value, - valueOnly + allowReserved, + explode, + name, + style, + value, + valueOnly, }: SerializeOptions & { - value: Record | Date; - valueOnly?: boolean; + value: Record | Date; + valueOnly?: boolean; }): string => { - if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; - } - - if (style !== 'deepObject' && !explode) { - let values: string[] = []; - Object.entries(value).forEach(([key, v]) => { - values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; - }); - const joinedValues = values.join(','); - switch (style) { - case 'form': - return `${name}=${joinedValues}`; - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - default: - return joinedValues; - } - } - - const separator = separatorObjectExplode(style); - const joinedValues = Object.entries(value) - .map(([key, v]) => - serializePrimitiveParam({ - allowReserved, - name: style === 'deepObject' ? `${name}[${key}]` : key, - value: v as string - }) - ) - .join(separator); - return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; }; diff --git a/frontend/src/lib/api/gen/core/queryKeySerializer.gen.ts b/frontend/src/lib/api/gen/core/queryKeySerializer.gen.ts index d7f354b..773b065 100644 --- a/frontend/src/lib/api/gen/core/queryKeySerializer.gen.ts +++ b/frontend/src/lib/api/gen/core/queryKeySerializer.gen.ts @@ -4,109 +4,114 @@ * JSON-friendly union that mirrors what Pinia Colada can hash. */ export type JsonValue = - null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue }; + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; /** * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. */ export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { - if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { - return undefined; - } - if (typeof value === 'bigint') { - return value.toString(); - } - if (value instanceof Date) { - return value.toISOString(); - } - return value; + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; }; /** * Safely stringifies a value and parses it back into a JsonValue. */ export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { - try { - const json = JSON.stringify(input, queryKeyJsonReplacer); - if (json === undefined) { - return undefined; - } - return JSON.parse(json) as JsonValue; - } catch { - return undefined; - } + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } }; /** * Detects plain objects (including objects with a null prototype). */ const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value as object); - return prototype === Object.prototype || prototype === null; + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; }; /** * Turns URLSearchParams into a sorted JSON object for deterministic keys. */ const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); - const result: Record = {}; - - for (const [key, value] of entries) { - const existing = result[key]; - if (existing === undefined) { - result[key] = value; - continue; - } - - if (Array.isArray(existing)) { - (existing as string[]).push(value); - } else { - result[key] = [existing, value]; - } - } - - return result; + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; }; /** * Normalizes any accepted value into a JSON-friendly shape for query keys. */ export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { - if (value === null) { - return null; - } + if (value === null) { + return null; + } - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value; - } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } - if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { - return undefined; - } + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } - if (typeof value === 'bigint') { - return value.toString(); - } + if (typeof value === 'bigint') { + return value.toString(); + } - if (value instanceof Date) { - return value.toISOString(); - } + if (value instanceof Date) { + return value.toISOString(); + } - if (Array.isArray(value)) { - return stringifyToJsonValue(value); - } + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } - if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { - return serializeSearchParams(value); - } + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } - if (isPlainObject(value)) { - return stringifyToJsonValue(value); - } + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } - return undefined; + return undefined; }; diff --git a/frontend/src/lib/api/gen/core/serverSentEvents.gen.ts b/frontend/src/lib/api/gen/core/serverSentEvents.gen.ts index 127e9a9..ddf3c4d 100644 --- a/frontend/src/lib/api/gen/core/serverSentEvents.gen.ts +++ b/frontend/src/lib/api/gen/core/serverSentEvents.gen.ts @@ -3,240 +3,240 @@ import type { Config } from './types.gen'; export type ServerSentEventsOptions = Omit & - Pick & { - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Implementing clients can call request interceptors inside this hook. - */ - onRequest?: (url: string, init: RequestInit) => Promise; - /** - * Callback invoked when a network or parsing error occurs during streaming. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param error The error that occurred. - */ - onSseError?: (error: unknown) => void; - /** - * Callback invoked when an event is streamed from the server. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param event Event streamed from the server. - * @returns Nothing (void). - */ - onSseEvent?: (event: StreamEvent) => void; - serializedBody?: RequestInit['body']; - /** - * Default retry delay in milliseconds. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 3000 - */ - sseDefaultRetryDelay?: number; - /** - * Maximum number of retry attempts before giving up. - */ - sseMaxRetryAttempts?: number; - /** - * Maximum retry delay in milliseconds. - * - * Applies only when exponential backoff is used. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 30000 - */ - sseMaxRetryDelay?: number; - /** - * Optional sleep function for retry backoff. - * - * Defaults to using `setTimeout`. - */ - sseSleepFn?: (ms: number) => Promise; - url: string; - }; + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; export interface StreamEvent { - data: TData; - event?: string; - id?: string; - retry?: number; + data: TData; + event?: string; + id?: string; + retry?: number; } export type ServerSentEventsResult = { - stream: AsyncGenerator< - TData extends Record ? TData[keyof TData] : TData, - TReturn, - TNext - >; + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; }; export function createSseClient({ - onRequest, - onSseError, - onSseEvent, - responseTransformer, - responseValidator, - sseDefaultRetryDelay, - sseMaxRetryAttempts, - sseMaxRetryDelay, - sseSleepFn, - url, - ...options + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options }: ServerSentEventsOptions): ServerSentEventsResult { - let lastEventId: string | undefined; - - const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); - - const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000; - let attempt = 0; - const signal = options.signal ?? new AbortController().signal; - - while (true) { - if (signal.aborted) break; - - attempt++; - - const headers = - options.headers instanceof Headers - ? options.headers - : new Headers(options.headers as Record | undefined); - - if (lastEventId !== undefined) { - headers.set('Last-Event-ID', lastEventId); - } - - try { - const requestInit: RequestInit = { - redirect: 'follow', - ...options, - body: options.serializedBody, - headers, - signal - }; - let request = new Request(url, requestInit); - if (onRequest) { - request = await onRequest(url, requestInit); - } - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch; - const response = await _fetch(request); - - if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); - - if (!response.body) throw new Error('No body in SSE response'); - - const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); - - let buffer = ''; - - const abortHandler = () => { - try { - reader.cancel(); - } catch { - // noop - } - }; - - signal.addEventListener('abort', abortHandler); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += value; - buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings - - const chunks = buffer.split('\n\n'); - buffer = chunks.pop() ?? ''; - - for (const chunk of chunks) { - const lines = chunk.split('\n'); - const dataLines: Array = []; - let eventName: string | undefined; - - for (const line of lines) { - if (line.startsWith('data:')) { - dataLines.push(line.replace(/^data:\s*/, '')); - } else if (line.startsWith('event:')) { - eventName = line.replace(/^event:\s*/, ''); - } else if (line.startsWith('id:')) { - lastEventId = line.replace(/^id:\s*/, ''); - } else if (line.startsWith('retry:')) { - const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); - if (!Number.isNaN(parsed)) { - retryDelay = parsed; - } - } - } - - let data: unknown; - let parsedJson = false; - - if (dataLines.length) { - const rawData = dataLines.join('\n'); - try { - data = JSON.parse(rawData); - parsedJson = true; - } catch { - data = rawData; - } - } - - if (parsedJson) { - if (responseValidator) { - await responseValidator(data); - } - - if (responseTransformer) { - data = await responseTransformer(data); - } - } - - onSseEvent?.({ - data, - event: eventName, - id: lastEventId, - retry: retryDelay - }); - - if (dataLines.length) { - yield data as any; - } - } - } - } finally { - signal.removeEventListener('abort', abortHandler); - reader.releaseLock(); - } - - break; // exit loop on normal completion - } catch (error) { - // connection failed or aborted; retry after delay - onSseError?.(error); - - if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { - break; // stop after firing error - } - - // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); - await sleep(backoff); - } - } - }; - - const stream = createStream(); - - return { stream }; + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; } diff --git a/frontend/src/lib/api/gen/core/types.gen.ts b/frontend/src/lib/api/gen/core/types.gen.ts index 9bff595..c657c85 100644 --- a/frontend/src/lib/api/gen/core/types.gen.ts +++ b/frontend/src/lib/api/gen/core/types.gen.ts @@ -4,83 +4,91 @@ import type { Auth, AuthToken } from './auth.gen'; import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; export type HttpMethod = - 'connect' | 'delete' | 'get' | 'head' | 'options' | 'patch' | 'post' | 'put' | 'trace'; + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; export type Client< - RequestFn = never, - Config = unknown, - MethodFn = never, - BuildUrlFn = never, - SseFn = never + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, > = { - /** - * Returns the final request URL. - */ - buildUrl: BuildUrlFn; - getConfig: () => Config; - request: RequestFn; - setConfig: (config: Config) => Config; + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; } & { - [K in HttpMethod]: MethodFn; + [K in HttpMethod]: MethodFn; } & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); export interface Config { - /** - * Auth token or a function returning auth token. The resolved value will be - * added to the request payload as defined by its `security` array. - */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; - /** - * A function for serializing request body parameter. By default, - * {@link JSON.stringify()} will be used. - */ - bodySerializer?: BodySerializer | null; - /** - * An object containing any HTTP headers that you want to pre-populate your - * `Headers` object with. - * - * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} - */ - headers?: - | RequestInit['headers'] - | Record< - string, - string | number | boolean | (string | number | boolean)[] | null | undefined | unknown - >; - /** - * The request method. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} - */ - method?: Uppercase; - /** - * A function for serializing request query parameters. By default, arrays - * will be exploded in form style, objects will be exploded in deepObject - * style, and reserved characters are percent-encoded. - * - * This method will have no effect if the native `paramsSerializer()` Axios - * API function is used. - * - * {@link https://swagger.io/docs/specification/serialization/#query View examples} - */ - querySerializer?: QuerySerializer | QuerySerializerOptions; - /** - * A function validating request data. This is useful if you want to ensure - * the request conforms to the desired shape, so it can be safely sent to - * the server. - */ - requestValidator?: (data: unknown) => Promise; - /** - * A function transforming response data before it's returned. This is useful - * for post-processing data, e.g., converting ISO strings into Date objects. - */ - responseTransformer?: (data: unknown) => Promise; - /** - * A function validating response data. This is useful if you want to ensure - * the response conforms to the desired shape, so it can be safely passed to - * the transformers and returned to the user. - */ - responseValidator?: (data: unknown) => Promise; + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; } /** @@ -90,13 +98,13 @@ export interface Config { export interface ClientMeta {} type IsExactlyNeverOrNeverUndefined = [T] extends [never] - ? true - : [T] extends [never | undefined] - ? [undefined] extends [T] - ? false - : true - : false; + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; }; diff --git a/frontend/src/lib/api/gen/core/utils.gen.ts b/frontend/src/lib/api/gen/core/utils.gen.ts index bb787af..af56e07 100644 --- a/frontend/src/lib/api/gen/core/utils.gen.ts +++ b/frontend/src/lib/api/gen/core/utils.gen.ts @@ -2,139 +2,139 @@ import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; import { - type ArraySeparatorStyle, - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, } from './pathSerializer.gen'; export interface PathSerializer { - path: Record; - url: string; + path: Record; + url: string; } export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); - if (matches) { - for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - - if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; - } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; - } - - const value = path[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - url = url.replace(match, serializeArrayParam({ explode, name, style, value })); - continue; - } - - if (typeof value === 'object') { - url = url.replace( - match, - serializeObjectParam({ - explode, - name, - style, - value: value as Record, - valueOnly: true - }) - ); - continue; - } - - if (style === 'matrix') { - url = url.replace( - match, - `;${serializePrimitiveParam({ - name, - value: value as string - })}` - ); - continue; - } - - const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string) - ); - url = url.replace(match, replaceValue); - } - } - return url; + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; }; export const getUrl = ({ - baseUrl, - path, - query, - querySerializer, - url: _url + baseUrl, + path, + query, + querySerializer, + url: _url, }: { - baseUrl?: string; - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; }): string => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = (baseUrl ?? '') + pathUrl; - if (path) { - url = defaultPathSerializer({ path, url }); - } - let search = query ? querySerializer(query) : ''; - if (search.startsWith('?')) { - search = search.substring(1); - } - if (search) { - url += `?${search}`; - } - return url; + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; }; export function getValidRequestBody(options: { - body?: unknown; - bodySerializer?: BodySerializer | null; - serializedBody?: unknown; + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; }): unknown { - const hasBody = options.body !== undefined; - const isSerializedBody = hasBody && options.bodySerializer; + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; - if (isSerializedBody) { - if ('serializedBody' in options) { - const hasSerializedBody = - options.serializedBody !== undefined && options.serializedBody !== ''; + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; - return hasSerializedBody ? options.serializedBody : null; - } + return hasSerializedBody ? options.serializedBody : null; + } - // not all clients implement a serializedBody property (i.e., client-axios) - return options.body !== '' ? options.body : null; - } + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== '' ? options.body : null; + } - // plain/text body - if (hasBody) { - return options.body; - } + // plain/text body + if (hasBody) { + return options.body; + } - // no body was provided - return undefined; + // no body was provided + return undefined; } diff --git a/frontend/src/lib/api/gen/index.ts b/frontend/src/lib/api/gen/index.ts index a4453ab..878f4a9 100644 --- a/frontend/src/lib/api/gen/index.ts +++ b/frontend/src/lib/api/gen/index.ts @@ -1,97 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { - cancelAnalysis, - chat, - createAnalysis, - deleteAnalysis, - exportObsidian, - getAnalysis, - getArticle, - getClusters, - getCorpus, - getEvents, - getOutliers, - health, - listAnalyses, - type Options, - streamAnalysis -} from './sdk.gen'; -export type { - Analysis, - AnalysisCreate, - AnalysisEvent, - Article, - CancelAnalysisData, - CancelAnalysisError, - CancelAnalysisErrors, - CancelAnalysisResponse, - CancelAnalysisResponses, - ChatData, - ChatError, - ChatErrors, - ChatRequest, - ChatResponse, - ChatResponse2, - ChatResponses, - Citation, - ClientOptions, - Cluster, - CorpusPoint, - CreateAnalysisData, - CreateAnalysisError, - CreateAnalysisErrors, - CreateAnalysisResponse, - CreateAnalysisResponses, - DeleteAnalysisData, - DeleteAnalysisError, - DeleteAnalysisErrors, - DeleteAnalysisResponse, - DeleteAnalysisResponses, - ExportObsidianData, - ExportObsidianError, - ExportObsidianErrors, - ExportObsidianResponses, - GetAnalysisData, - GetAnalysisError, - GetAnalysisErrors, - GetAnalysisResponse, - GetAnalysisResponses, - GetArticleData, - GetArticleError, - GetArticleErrors, - GetArticleResponse, - GetArticleResponses, - GetClustersData, - GetClustersError, - GetClustersErrors, - GetClustersResponse, - GetClustersResponses, - GetCorpusData, - GetCorpusError, - GetCorpusErrors, - GetCorpusResponse, - GetCorpusResponses, - GetEventsData, - GetEventsError, - GetEventsErrors, - GetEventsResponse, - GetEventsResponses, - GetOutliersData, - GetOutliersError, - GetOutliersErrors, - GetOutliersResponse, - GetOutliersResponses, - HealthData, - HealthResponses, - HttpValidationError, - ListAnalysesData, - ListAnalysesResponse, - ListAnalysesResponses, - Outlier, - StreamAnalysisData, - StreamAnalysisError, - StreamAnalysisErrors, - StreamAnalysisResponses, - ValidationError -} from './types.gen'; +export { cancelAnalysis, chat, createAnalysis, deleteAnalysis, exportObsidian, getAnalysis, getArticle, getClusters, getEvents, health, listAnalyses, listArticles, type Options, streamAnalysis } from './sdk.gen'; +export type { Analysis, AnalysisCreate, AnalysisEvent, Article, CancelAnalysisData, CancelAnalysisError, CancelAnalysisErrors, CancelAnalysisResponse, CancelAnalysisResponses, ChatData, ChatError, ChatErrors, ChatRequest, ChatResponse, ChatResponse2, ChatResponses, Citation, ClientOptions, Cluster, CreateAnalysisData, CreateAnalysisError, CreateAnalysisErrors, CreateAnalysisResponse, CreateAnalysisResponses, DeleteAnalysisData, DeleteAnalysisError, DeleteAnalysisErrors, DeleteAnalysisResponse, DeleteAnalysisResponses, ExportObsidianData, ExportObsidianError, ExportObsidianErrors, ExportObsidianResponses, GetAnalysisData, GetAnalysisError, GetAnalysisErrors, GetAnalysisResponse, GetAnalysisResponses, GetArticleData, GetArticleError, GetArticleErrors, GetArticleResponse, GetArticleResponses, GetClustersData, GetClustersError, GetClustersErrors, GetClustersResponse, GetClustersResponses, GetEventsData, GetEventsError, GetEventsErrors, GetEventsResponse, GetEventsResponses, HealthData, HealthResponses, HttpValidationError, ListAnalysesData, ListAnalysesResponse, ListAnalysesResponses, ListArticlesData, ListArticlesError, ListArticlesErrors, ListArticlesResponse, ListArticlesResponses, MapArticle, StreamAnalysisData, StreamAnalysisError, StreamAnalysisErrors, StreamAnalysisResponses, Term, ValidationError } from './types.gen'; diff --git a/frontend/src/lib/api/gen/sdk.gen.ts b/frontend/src/lib/api/gen/sdk.gen.ts index 4c45945..3286f55 100644 --- a/frontend/src/lib/api/gen/sdk.gen.ts +++ b/frontend/src/lib/api/gen/sdk.gen.ts @@ -2,149 +2,65 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { - CancelAnalysisData, - CancelAnalysisErrors, - CancelAnalysisResponses, - ChatData, - ChatErrors, - ChatResponses, - CreateAnalysisData, - CreateAnalysisErrors, - CreateAnalysisResponses, - DeleteAnalysisData, - DeleteAnalysisErrors, - DeleteAnalysisResponses, - ExportObsidianData, - ExportObsidianErrors, - ExportObsidianResponses, - GetAnalysisData, - GetAnalysisErrors, - GetAnalysisResponses, - GetArticleData, - GetArticleErrors, - GetArticleResponses, - GetClustersData, - GetClustersErrors, - GetClustersResponses, - GetCorpusData, - GetCorpusErrors, - GetCorpusResponses, - GetEventsData, - GetEventsErrors, - GetEventsResponses, - GetOutliersData, - GetOutliersErrors, - GetOutliersResponses, - HealthData, - HealthResponses, - ListAnalysesData, - ListAnalysesResponses, - StreamAnalysisData, - StreamAnalysisErrors, - StreamAnalysisResponses -} from './types.gen'; - -export type Options< - TData extends TDataShape = TDataShape, - ThrowOnError extends boolean = boolean, - TResponse = unknown -> = Options2 & { - /** - * You can provide a client instance returned by `createClient()` instead of - * individual options. This might be also useful if you want to implement a - * custom client. - */ - client?: Client; - /** - * You can pass arbitrary values through the `meta` object. This can be - * used to access values that aren't defined as part of the SDK function. - */ - meta?: keyof ClientMeta extends never ? Record : ClientMeta; +import type { CancelAnalysisData, CancelAnalysisErrors, CancelAnalysisResponses, ChatData, ChatErrors, ChatResponses, CreateAnalysisData, CreateAnalysisErrors, CreateAnalysisResponses, DeleteAnalysisData, DeleteAnalysisErrors, DeleteAnalysisResponses, ExportObsidianData, ExportObsidianErrors, ExportObsidianResponses, GetAnalysisData, GetAnalysisErrors, GetAnalysisResponses, GetArticleData, GetArticleErrors, GetArticleResponses, GetClustersData, GetClustersErrors, GetClustersResponses, GetEventsData, GetEventsErrors, GetEventsResponses, HealthData, HealthResponses, ListAnalysesData, ListAnalysesResponses, ListArticlesData, ListArticlesErrors, ListArticlesResponses, StreamAnalysisData, StreamAnalysisErrors, StreamAnalysisResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: keyof ClientMeta extends never ? Record : ClientMeta; }; /** * Health */ -export const health = ( - options?: Options -): RequestResult => - (options?.client ?? client).get({ - url: '/api/health', - ...options - }); +export const health = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/health', ...options }); /** * List Analyses */ -export const listAnalyses = ( - options?: Options -): RequestResult => - (options?.client ?? client).get({ - url: '/api/analyses', - ...options - }); +export const listAnalyses = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/analyses', ...options }); /** * Create Analysis */ -export const createAnalysis = ( - options: Options -): RequestResult => - (options.client ?? client).post({ - url: '/api/analyses', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); +export const createAnalysis = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/analyses', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** * Delete Analysis */ -export const deleteAnalysis = ( - options: Options -): RequestResult => - (options.client ?? client).delete({ - url: '/api/analyses/{analysis_id}', - ...options - }); +export const deleteAnalysis = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/analyses/{analysis_id}', ...options }); /** * Get Analysis */ -export const getAnalysis = ( - options: Options -): RequestResult => - (options.client ?? client).get({ - url: '/api/analyses/{analysis_id}', - ...options - }); +export const getAnalysis = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/analyses/{analysis_id}', ...options }); /** * Cancel Analysis * * Ask a running analysis to stop; it reaches status 'cancelled' shortly after. */ -export const cancelAnalysis = ( - options: Options -): RequestResult => - (options.client ?? client).post({ - url: '/api/analyses/{analysis_id}/cancel', - ...options - }); +export const cancelAnalysis = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/analyses/{analysis_id}/cancel', ...options }); /** * Get Events */ -export const getEvents = ( - options: Options -): RequestResult => - (options.client ?? client).get({ - url: '/api/analyses/{analysis_id}/events', - ...options - }); +export const getEvents = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/analyses/{analysis_id}/events', ...options }); /** * Stream Analysis @@ -154,80 +70,40 @@ export const getEvents = ( * `log` events (with `id:`) — new rows from the event log; EventSource * resends Last-Event-ID on reconnect, so the log resumes where it left off. */ -export const streamAnalysis = ( - options: Options -): RequestResult => - (options.client ?? client).get({ - url: '/api/analyses/{analysis_id}/stream', - ...options - }); +export const streamAnalysis = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/analyses/{analysis_id}/stream', ...options }); /** - * Get Corpus + * List Articles + * + * Every article on the map, with its position, topic and outlier score. */ -export const getCorpus = ( - options: Options -): RequestResult => - (options.client ?? client).get({ - url: '/api/analyses/{analysis_id}/corpus', - ...options - }); +export const listArticles = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/analyses/{analysis_id}/articles', ...options }); /** * Get Clusters + * + * Topics, largest first, with their key terms. Unclustered articles have none. */ -export const getClusters = ( - options: Options -): RequestResult => - (options.client ?? client).get({ - url: '/api/analyses/{analysis_id}/clusters', - ...options - }); - -/** - * Get Outliers - */ -export const getOutliers = ( - options: Options -): RequestResult => - (options.client ?? client).get({ - url: '/api/analyses/{analysis_id}/outliers', - ...options - }); +export const getClusters = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/analyses/{analysis_id}/clusters', ...options }); /** * Get Article */ -export const getArticle = ( - options: Options -): RequestResult => - (options.client ?? client).get({ - url: '/api/analyses/{analysis_id}/articles/{article_id}', - ...options - }); +export const getArticle = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/analyses/{analysis_id}/articles/{article_id}', ...options }); /** * Chat */ -export const chat = ( - options: Options -): RequestResult => - (options.client ?? client).post({ - url: '/api/analyses/{analysis_id}/chat', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); +export const chat = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/analyses/{analysis_id}/chat', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** * Export Obsidian */ -export const exportObsidian = ( - options: Options -): RequestResult => - (options.client ?? client).get({ - url: '/api/analyses/{analysis_id}/export', - ...options - }); +export const exportObsidian = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/analyses/{analysis_id}/export', ...options }); diff --git a/frontend/src/lib/api/gen/types.gen.ts b/frontend/src/lib/api/gen/types.gen.ts index 4e0d1c0..8ff904e 100644 --- a/frontend/src/lib/api/gen/types.gen.ts +++ b/frontend/src/lib/api/gen/types.gen.ts @@ -1,750 +1,679 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}); + baseUrl: `${string}://${string}` | (string & {}); }; /** * Analysis */ export type Analysis = { - /** - * Id - */ - id: string; - /** - * Topic - */ - topic: string; - /** - * Status - */ - status: - | 'queued' - | 'crawling' - | 'processing' - | 'embedding' - | 'projecting' - | 'done' - | 'error' - | 'cancelled'; - /** - * Progress - */ - progress: number; - /** - * Stage Detail - */ - stage_detail: string | null; - /** - * Article Count - */ - article_count: number; - /** - * Error Msg - */ - error_msg: string | null; - /** - * Warning - */ - warning: string | null; - /** - * Created Ts - */ - created_ts: number; - /** - * Updated Ts - */ - updated_ts: number; + /** + * Id + */ + id: string; + /** + * Topic + */ + topic: string; + /** + * Status + */ + status: 'queued' | 'crawling' | 'processing' | 'embedding' | 'projecting' | 'done' | 'error' | 'cancelled'; + /** + * Progress + */ + progress: number; + /** + * Stage Detail + */ + stage_detail: string | null; + /** + * Article Count + */ + article_count: number; + /** + * Error Msg + */ + error_msg: string | null; + /** + * Warning + */ + warning: string | null; + /** + * Created Ts + */ + created_ts: number; + /** + * Updated Ts + */ + updated_ts: number; }; /** * AnalysisCreate */ export type AnalysisCreate = { - /** - * Topic - */ - topic: string; + /** + * Topic + */ + topic: string; }; /** * AnalysisEvent */ export type AnalysisEvent = { - /** - * Id - */ - id: number; - /** - * Ts - */ - ts: number; - /** - * Kind - */ - kind: 'stage' | 'query' | 'article' | 'skip' | 'cluster' | 'warning' | 'error'; - /** - * Message - */ - message: string; - /** - * Url - */ - url: string | null; + /** + * Id + */ + id: number; + /** + * Ts + */ + ts: number; + /** + * Kind + */ + kind: 'stage' | 'query' | 'article' | 'skip' | 'cluster' | 'warning' | 'error'; + /** + * Message + */ + message: string; + /** + * Url + */ + url: string | null; }; /** * Article */ export type Article = { - /** - * Id - */ - id: string; - /** - * Analysis Id - */ - analysis_id: string; - /** - * Url - */ - url: string; - /** - * Domain - */ - domain: string | null; - /** - * Title - */ - title: string | null; - /** - * Publish Date - */ - publish_date: string | null; - /** - * Crawl Ts - */ - crawl_ts: number | null; - /** - * Word Count - */ - word_count: number | null; - /** - * Language - */ - language: string | null; - /** - * Raw Text - */ - raw_text: string | null; - /** - * Status - */ - status: string; + /** + * Id + */ + id: string; + /** + * Analysis Id + */ + analysis_id: string; + /** + * Url + */ + url: string; + /** + * Domain + */ + domain: string | null; + /** + * Title + */ + title: string | null; + /** + * Publish Date + */ + publish_date: string | null; + /** + * Crawl Ts + */ + crawl_ts: number | null; + /** + * Word Count + */ + word_count: number | null; + /** + * Language + */ + language: string | null; + /** + * Raw Text + */ + raw_text: string | null; + /** + * Status + */ + status: string; }; /** * ChatRequest */ export type ChatRequest = { - /** - * Query - */ - query: string; + /** + * Query + */ + query: string; }; /** * ChatResponse */ export type ChatResponse = { - /** - * Answer - */ - answer: string; - /** - * Citations - */ - citations: Array; + /** + * Answer + */ + answer: string; + /** + * Citations + */ + citations: Array; }; /** * Citation */ export type Citation = { - /** - * Url - */ - url: string; - /** - * Title - */ - title: string; - /** - * Domain - */ - domain: string; + /** + * Url + */ + url: string; + /** + * Title + */ + title: string; + /** + * Domain + */ + domain: string; }; /** * Cluster */ export type Cluster = { - /** - * Cluster Id - */ - cluster_id: number; - /** - * Cluster Label - */ - cluster_label: string | null; - /** - * Article Count - */ - article_count: number; + /** + * Cluster Id + */ + cluster_id: number; + /** + * Label + */ + label: string; + /** + * Description + */ + description: string | null; + /** + * Article Count + */ + article_count: number; + /** + * Terms + */ + terms: Array; }; /** - * CorpusPoint + * HTTPValidationError */ -export type CorpusPoint = { - /** - * Chunk Id - */ - chunk_id: string; - /** - * Article Id - */ - article_id: string; - /** - * Child Text - */ - child_text: string | null; - /** - * Url - */ - url: string; - /** - * Title - */ - title: string | null; - /** - * Domain - */ - domain: string | null; - /** - * Publish Date - */ - publish_date: string | null; - /** - * X - */ - x: number; - /** - * Y - */ - y: number; - /** - * Cluster Id - */ - cluster_id: number; - /** - * Cluster Label - */ - cluster_label: string | null; - /** - * Distance From Centroid - */ - distance_from_centroid: number; - /** - * Word Count - */ - word_count: number | null; +export type HttpValidationError = { + /** + * Detail + */ + detail?: Array; }; /** - * HTTPValidationError + * MapArticle + * + * An article as placed on the map. */ -export type HttpValidationError = { - /** - * Detail - */ - detail?: Array; +export type MapArticle = { + /** + * Article Id + */ + article_id: string; + /** + * Url + */ + url: string; + /** + * Title + */ + title: string | null; + /** + * Domain + */ + domain: string | null; + /** + * Publish Date + */ + publish_date: string | null; + /** + * Word Count + */ + word_count: number | null; + /** + * X + */ + x: number; + /** + * Y + */ + y: number; + /** + * Cluster Id + */ + cluster_id: number; + /** + * Distance + */ + distance: number; }; /** - * Outlier + * Term */ -export type Outlier = { - /** - * Chunk Id - */ - chunk_id: string; - /** - * Cluster Id - */ - cluster_id: number; - /** - * Cluster Label - */ - cluster_label: string | null; - /** - * Distance From Centroid - */ - distance_from_centroid: number; - /** - * Article Id - */ - article_id: string; - /** - * Title - */ - title: string | null; - /** - * Domain - */ - domain: string | null; - /** - * Publish Date - */ - publish_date: string | null; +export type Term = { + /** + * Term + */ + term: string; + /** + * Weight + */ + weight: number; }; /** * ValidationError */ export type ValidationError = { - /** - * Location - */ - loc: Array; - /** - * Message - */ - msg: string; - /** - * Error Type - */ - type: string; - /** - * Input - */ - input?: unknown; - /** - * Context - */ - ctx?: { - [key: string]: unknown; - }; + /** + * Location + */ + loc: Array; + /** + * Message + */ + msg: string; + /** + * Error Type + */ + type: string; + /** + * Input + */ + input?: unknown; + /** + * Context + */ + ctx?: { + [key: string]: unknown; + }; }; export type HealthData = { - body?: never; - path?: never; - query?: never; - url: '/api/health'; + body?: never; + path?: never; + query?: never; + url: '/api/health'; }; export type HealthResponses = { - /** - * Successful Response - */ - 200: unknown; + /** + * Successful Response + */ + 200: unknown; }; export type ListAnalysesData = { - body?: never; - path?: never; - query?: never; - url: '/api/analyses'; + body?: never; + path?: never; + query?: never; + url: '/api/analyses'; }; export type ListAnalysesResponses = { - /** - * Response List Analyses - * - * Successful Response - */ - 200: Array; + /** + * Response List Analyses + * + * Successful Response + */ + 200: Array; }; export type ListAnalysesResponse = ListAnalysesResponses[keyof ListAnalysesResponses]; export type CreateAnalysisData = { - body: AnalysisCreate; - path?: never; - query?: never; - url: '/api/analyses'; + body: AnalysisCreate; + path?: never; + query?: never; + url: '/api/analyses'; }; export type CreateAnalysisErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type CreateAnalysisError = CreateAnalysisErrors[keyof CreateAnalysisErrors]; export type CreateAnalysisResponses = { - /** - * Successful Response - */ - 201: Analysis; + /** + * Successful Response + */ + 201: Analysis; }; export type CreateAnalysisResponse = CreateAnalysisResponses[keyof CreateAnalysisResponses]; export type DeleteAnalysisData = { - body?: never; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: never; - url: '/api/analyses/{analysis_id}'; + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}'; }; export type DeleteAnalysisErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type DeleteAnalysisError = DeleteAnalysisErrors[keyof DeleteAnalysisErrors]; export type DeleteAnalysisResponses = { - /** - * Successful Response - */ - 204: void; + /** + * Successful Response + */ + 204: void; }; export type DeleteAnalysisResponse = DeleteAnalysisResponses[keyof DeleteAnalysisResponses]; export type GetAnalysisData = { - body?: never; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: never; - url: '/api/analyses/{analysis_id}'; + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}'; }; export type GetAnalysisErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type GetAnalysisError = GetAnalysisErrors[keyof GetAnalysisErrors]; export type GetAnalysisResponses = { - /** - * Successful Response - */ - 200: Analysis; + /** + * Successful Response + */ + 200: Analysis; }; export type GetAnalysisResponse = GetAnalysisResponses[keyof GetAnalysisResponses]; export type CancelAnalysisData = { - body?: never; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: never; - url: '/api/analyses/{analysis_id}/cancel'; + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/cancel'; }; export type CancelAnalysisErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type CancelAnalysisError = CancelAnalysisErrors[keyof CancelAnalysisErrors]; export type CancelAnalysisResponses = { - /** - * Successful Response - */ - 202: Analysis; + /** + * Successful Response + */ + 202: Analysis; }; export type CancelAnalysisResponse = CancelAnalysisResponses[keyof CancelAnalysisResponses]; export type GetEventsData = { - body?: never; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: { - /** - * After - */ - after?: number; - }; - url: '/api/analyses/{analysis_id}/events'; + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: { + /** + * After + */ + after?: number; + }; + url: '/api/analyses/{analysis_id}/events'; }; export type GetEventsErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type GetEventsError = GetEventsErrors[keyof GetEventsErrors]; export type GetEventsResponses = { - /** - * Response Get Events - * - * Successful Response - */ - 200: Array; + /** + * Response Get Events + * + * Successful Response + */ + 200: Array; }; export type GetEventsResponse = GetEventsResponses[keyof GetEventsResponses]; export type StreamAnalysisData = { - body?: never; - headers?: { - /** - * Last-Event-Id - */ - 'last-event-id'?: number; - }; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: never; - url: '/api/analyses/{analysis_id}/stream'; + body?: never; + headers?: { + /** + * Last-Event-Id + */ + 'last-event-id'?: number; + }; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/stream'; }; export type StreamAnalysisErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type StreamAnalysisError = StreamAnalysisErrors[keyof StreamAnalysisErrors]; export type StreamAnalysisResponses = { - /** - * Successful Response - */ - 200: unknown; + /** + * Successful Response + */ + 200: unknown; }; -export type GetCorpusData = { - body?: never; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: never; - url: '/api/analyses/{analysis_id}/corpus'; +export type ListArticlesData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/articles'; }; -export type GetCorpusErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; +export type ListArticlesErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; }; -export type GetCorpusError = GetCorpusErrors[keyof GetCorpusErrors]; +export type ListArticlesError = ListArticlesErrors[keyof ListArticlesErrors]; -export type GetCorpusResponses = { - /** - * Response Get Corpus - * - * Successful Response - */ - 200: Array; +export type ListArticlesResponses = { + /** + * Response List Articles + * + * Successful Response + */ + 200: Array; }; -export type GetCorpusResponse = GetCorpusResponses[keyof GetCorpusResponses]; +export type ListArticlesResponse = ListArticlesResponses[keyof ListArticlesResponses]; export type GetClustersData = { - body?: never; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: never; - url: '/api/analyses/{analysis_id}/clusters'; + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/clusters'; }; export type GetClustersErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type GetClustersError = GetClustersErrors[keyof GetClustersErrors]; export type GetClustersResponses = { - /** - * Response Get Clusters - * - * Successful Response - */ - 200: Array; + /** + * Response Get Clusters + * + * Successful Response + */ + 200: Array; }; export type GetClustersResponse = GetClustersResponses[keyof GetClustersResponses]; -export type GetOutliersData = { - body?: never; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: { - /** - * Cluster Id - */ - cluster_id?: number | null; - }; - url: '/api/analyses/{analysis_id}/outliers'; -}; - -export type GetOutliersErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetOutliersError = GetOutliersErrors[keyof GetOutliersErrors]; - -export type GetOutliersResponses = { - /** - * Response Get Outliers - * - * Successful Response - */ - 200: Array; -}; - -export type GetOutliersResponse = GetOutliersResponses[keyof GetOutliersResponses]; - export type GetArticleData = { - body?: never; - path: { - /** - * Analysis Id - */ - analysis_id: string; - /** - * Article Id - */ - article_id: string; - }; - query?: never; - url: '/api/analyses/{analysis_id}/articles/{article_id}'; + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + /** + * Article Id + */ + article_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/articles/{article_id}'; }; export type GetArticleErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type GetArticleError = GetArticleErrors[keyof GetArticleErrors]; export type GetArticleResponses = { - /** - * Successful Response - */ - 200: Article; + /** + * Successful Response + */ + 200: Article; }; export type GetArticleResponse = GetArticleResponses[keyof GetArticleResponses]; export type ChatData = { - body: ChatRequest; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: never; - url: '/api/analyses/{analysis_id}/chat'; + body: ChatRequest; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/chat'; }; export type ChatErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type ChatError = ChatErrors[keyof ChatErrors]; export type ChatResponses = { - /** - * Successful Response - */ - 200: ChatResponse; + /** + * Successful Response + */ + 200: ChatResponse; }; export type ChatResponse2 = ChatResponses[keyof ChatResponses]; export type ExportObsidianData = { - body?: never; - path: { - /** - * Analysis Id - */ - analysis_id: string; - }; - query?: never; - url: '/api/analyses/{analysis_id}/export'; + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/export'; }; export type ExportObsidianErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + /** + * Validation Error + */ + 422: HttpValidationError; }; export type ExportObsidianError = ExportObsidianErrors[keyof ExportObsidianErrors]; export type ExportObsidianResponses = { - /** - * Successful Response - */ - 200: unknown; + /** + * Successful Response + */ + 200: unknown; }; diff --git a/frontend/src/lib/components/ClusterKey.svelte b/frontend/src/lib/components/ClusterKey.svelte index e038ed5..e4f167f 100644 --- a/frontend/src/lib/components/ClusterKey.svelte +++ b/frontend/src/lib/components/ClusterKey.svelte @@ -1,10 +1,12 @@
@@ -28,6 +30,7 @@ `; + }) + .join(''); + }); +} diff --git a/frontend/src/lib/components/Answer.svelte b/frontend/src/lib/components/Answer.svelte new file mode 100644 index 0000000..5b950df --- /dev/null +++ b/frontend/src/lib/components/Answer.svelte @@ -0,0 +1,156 @@ + + +{#snippet sourceRow(s: ChatSource)} +
  • + {s.n} + {#if ws.articles.some((a) => a.id === s.article_id)} + + {:else} + + + {s.title} + + + {/if} + {s.domain} +
  • +{/snippet} + +
    + + +
    + + {@html html} +
    + + {#if cited.length} +

    Sources

    +
      + {#each cited as s (s.n)}{@render sourceRow(s)}{/each} +
    + {/if} + {#if uncited.length} +
    + + Also searched, not cited ({uncited.length}) + +
      + {#each uncited as s (s.n)}{@render sourceRow(s)}{/each} +
    +
    + {/if} +
    + + diff --git a/frontend/src/routes/analysis/[id]/ask/+page.svelte b/frontend/src/routes/analysis/[id]/ask/+page.svelte index 39b7a83..9bd8190 100644 --- a/frontend/src/routes/analysis/[id]/ask/+page.svelte +++ b/frontend/src/routes/analysis/[id]/ask/+page.svelte @@ -1,59 +1,80 @@
    -
    +
    - {#if chat.messages.length === 0} + {#if chat.loadError} + + {:else if chat.loaded && chat.messages.length === 0 && !chat.draft}

    Ask the corpus

    - Answers come only from the {ws.articles.length} articles in this map, with the sources they - drew on listed underneath. + Answers come only from the {plural(ws.articles.length, 'article')} in this map. Numbers like + 2 point to the article each claim rests on; click one to read + it.

      {#each STARTERS as s (s)} @@ -68,6 +89,17 @@ {/each}
    + {:else if chat.messages.length > 0} +
    + +
    {/if}
    @@ -75,52 +107,26 @@ {#if m.role === 'user'}

    {m.text}

    {:else} -
    - -
    {@html render(m.text)}
    - {#if m.citations?.length} -

    Sources

    -
      - {#each m.citations as c, j (c.url)} - {@const local = ws.articleByUrl(c.url)} -
    1. - {j + 1}. - {#if local} - - {:else} - - - {c.title} - - - {/if} - {c.domain} -
    2. - {/each} -
    - {/if} -
    + {/if} {/each} - {#if chat.pending} -

    - - Reading the corpus… + {#if chat.draft} +

    + {chat.draft.question}

    + {#if chat.draft.text} + + {:else} +

    + + {chat.draft.sources.length + ? `Reading ${plural(chat.draft.sources.length, 'article')}…` + : 'Searching the corpus…'} +

    + {/if} {/if} + {#if chat.error} + + From 03e39745e9e1209349327b870342b8e17008ba41 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Tue, 13 Oct 2026 23:22:32 -0400 Subject: [PATCH 11/16] Recover academic articles that publishers block Academic publishers serve headless browsers a bot check or paywall, so ~45% of crawled pages on research-heavy topics were discarded as too short. backend/ingestion/scholarly.py looks them up instead: - Identify the work from the URL (DOI, PMC, PubMed, Elsevier PII via Crossref, arXiv, MDPI's ISSN/volume/issue/article scheme) or citation meta tags - Text, best first: Europe PMC full text; open-access PDFs from OpenAlex locations (several tried: publisher PDFs are often blocked too, while repository copies work); else the abstract. PDF links the crawler skipped are read directly. Capped at 8,000 words per paper - Runs 8 lookups at a time after extraction, cancellable, with events; articles record where their text came from (source_note, schema v6) and the reader shows it. RECOVER_ACADEMIC=false turns it off Measured on real maps: 51 of 126 previously skipped pages recovered from the event logs; a fresh urban-heat crawl indexed 72 articles instead of 44 (36 of 47 academic pages recovered in 25 s). Co-Authored-By: Claude Opus 5.5 --- .env.example | 1 + README.md | 4 +- backend/api/main.py | 1 + backend/config.py | 3 + backend/db/__init__.py | 3 +- backend/db/schema.sql | 3 +- backend/ingestion/scholarly.py | 258 ++++++++++++++++++ backend/pipeline.py | 97 ++++++- frontend/openapi.json | 11 + frontend/src/lib/api/gen/types.gen.ts | 4 + .../src/lib/components/ArticleSheet.svelte | 12 + pyproject.toml | 1 + tests/test_migration.py | 2 +- tests/test_pipeline.py | 47 ++++ tests/test_scholarly.py | 173 ++++++++++++ uv.lock | 11 + 16 files changed, 623 insertions(+), 8 deletions(-) create mode 100644 backend/ingestion/scholarly.py create mode 100644 tests/test_scholarly.py diff --git a/.env.example b/.env.example index 94b3d9b..b2380ee 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,4 @@ UMAP_N_NEIGHBORS=15 KMEANS_K=auto # auto | integer (e.g. 7) RAG_TOP_K=15 CHAT_EFFORT=medium # low | medium | high: reasoning before chat answers +RECOVER_ACADEMIC=true # look up open-access copies of pages academic publishers block diff --git a/README.md b/README.md index 5fa433f..7b983a0 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Checks: `uv run pytest`, `uv run ruff check backend tests`, and in `frontend/`: ## Using it 1. On the home page, type a topic (e.g. `mechanistic interpretability in large language models`) and choose **Map this topic**. -2. A map takes **3–8 minutes**. You'll see each step as it happens: the search queries, pages crawled, articles kept or skipped (and why), and clusters as they're named. You can leave the page, or **Cancel this map**. +2. A map takes **3–8 minutes**. Academic publishers often block automated reading; for those pages (and for PDF links) Discourse Lens looks up an open-access copy or the abstract through OpenAlex and Europe PMC, and the article reader says where the text came from. You'll see each step as it happens: the search queries, pages crawled, articles kept or skipped (and why), and clusters as they're named. You can leave the page, or **Cancel this map**. 3. When it's done you get five views of the same map: - **Map**: articles that make similar arguments sit close together, grouped into topics (camps) with contour lines and a name for each. Switch to **Table** for a plain list. - **Timeline**: articles per month or year, by cluster. @@ -122,6 +122,8 @@ Avoid topics that are too broad (`AI`, `climate change`) or too narrow (fewer th ## Troubleshooting +**Don't want the open-access lookups?** Set `RECOVER_ACADEMIC=false` in `.env`; academic pages a publisher blocks are then skipped. + **Pipeline stalls at "Crawling"** — Check your `BRAVE_API_KEY` in `.env`. **Cluster labels show as "cluster 0", "cluster 1"...** — The Anthropic API call for labelling failed. Check your `ANTHROPIC_API_KEY`. diff --git a/backend/api/main.py b/backend/api/main.py index ebd6c66..5840e1f 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -123,6 +123,7 @@ class Article(BaseModel): language: str | None raw_text: str | None status: str + source_note: str | None = None # set when the text came from an open-access copy class ChatRequest(BaseModel): diff --git a/backend/config.py b/backend/config.py index f0488bf..c809003 100644 --- a/backend/config.py +++ b/backend/config.py @@ -19,6 +19,9 @@ class Settings(BaseSettings): brave_api_key: str = "" max_articles: int = 120 + # Look up open-access copies of academic pages the crawler can't read (OpenAlex, Europe PMC) + recover_academic: bool = True + # Claude claude_backend: Literal["anthropic", "vertex"] = "anthropic" claude_model: str = "claude-sonnet-5-5" diff --git a/backend/db/__init__.py b/backend/db/__init__.py index b553aaa..268f61b 100644 --- a/backend/db/__init__.py +++ b/backend/db/__init__.py @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 _SCHEMA = Path(__file__).parent / "schema.sql" # Chunk-level map rows used by schema v2/v3, before placements + clusters. @@ -57,6 +57,7 @@ def init_db(db_path: str | None = None) -> None: # v4 -> v5: chat_messages table (schema script). conn.executescript(_SCHEMA.read_text()) _ensure_column(conn, "analyses", "stage_detail", "TEXT") + _ensure_column(conn, "articles", "source_note", "TEXT") # v5 -> v6 if _table_exists(conn, "projections"): _migrate_projections(conn) conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") diff --git a/backend/db/schema.sql b/backend/db/schema.sql index f92afb0..a19678f 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -1,4 +1,4 @@ --- Schema version 5 (PRAGMA user_version = 5): every row belongs to an analysis. +-- Schema version 6 (PRAGMA user_version = 6): every row belongs to an analysis. -- Deleting an analysis cascades to everything it collected -- (requires PRAGMA foreign_keys = ON, set by backend.db.connect). @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS articles ( language TEXT DEFAULT 'en', raw_text TEXT, status TEXT DEFAULT 'ok', -- ok | filtered | duplicate | error + source_note TEXT, -- where recovered text came from, e.g. "Full text from Europe PMC" UNIQUE (analysis_id, url) ); CREATE INDEX IF NOT EXISTS idx_articles_analysis ON articles(analysis_id); diff --git a/backend/ingestion/scholarly.py b/backend/ingestion/scholarly.py new file mode 100644 index 0000000..b1df3c5 --- /dev/null +++ b/backend/ingestion/scholarly.py @@ -0,0 +1,258 @@ +""" +Recover academic articles the crawler couldn't read. + +Publishers (ScienceDirect, PMC, MDPI, Wiley, …) often serve a headless browser +a bot check or paywall instead of the article. Many of those articles are +openly available elsewhere, and scholarly indexes know where: + +1. Identify the work: a DOI, PMC id, PubMed id or Elsevier PII in the URL, + MDPI's ISSN/volume/issue/article URL scheme, or citation meta tags in the + page we did get. +2. Look it up in OpenAlex (free, no key). Elsevier PIIs go via Crossref. +3. Get text, best first: Europe PMC full text (PMC articles); an open-access + PDF from OpenAlex's locations; otherwise the abstract. + +Measured on the 126 pages real maps skipped as "too short", about 40% are +recoverable this way (mostly full text). +""" + +from __future__ import annotations + +import io +import logging +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from urllib.parse import unquote, urlparse + +import httpx + +log = logging.getLogger(__name__) +logging.getLogger("pypdf").setLevel(logging.ERROR) # font-encoding chatter, irrelevant to text + +OPENALEX = "https://api.openalex.org" +CROSSREF = "https://api.crossref.org" +EUROPE_PMC = "https://www.ebi.ac.uk/europepmc/webservices/rest" +USER_AGENT = "discourse-lens/0.2 (open-source research tool)" + +MIN_ABSTRACT_WORDS = 60 +MAX_PDF_PAGES = 40 +MAX_WORDS = 8000 # a paper's substance; keeps a few long papers from dominating answers +MAX_PDF_ATTEMPTS = 3 + + +@dataclass +class Identifier: + kind: str # doi | pmcid | pmid | pii | mdpi + value: str + + +@dataclass +class Recovered: + title: str | None + text: str + publish_date: str | None # YYYY-MM-DD + note: str # where the text came from, shown to the reader + + +def new_client() -> httpx.Client: + return httpx.Client(timeout=25, follow_redirects=True, headers={"User-Agent": USER_AGENT}) + + +# ── identify ───────────────────────────────────────────────────────────────── + +_DOI = re.compile(r"(10\.\d{4,9}/[^\s?#&\"'<>]+)") +_DOI_SUFFIXES = re.compile(r"(/(full|abstract|pdf|xml|epdf|meta|html|fulltext))+/?$", re.I) +_META = re.compile( + r"]+name=[\"'](citation_doi|dc\.identifier|prism\.doi|citation_pmid)[\"'][^>]*" + r"content=[\"']([^\"']+)[\"']", + re.I, +) + + +def _clean_doi(doi: str) -> str: + doi = re.sub(r"^(https?://(dx\.)?doi\.org/|doi:)", "", doi.strip(), flags=re.I) + return _DOI_SUFFIXES.sub("", doi.rstrip("/.")) + + +def identify(url: str, html: str | None = None) -> Identifier | None: + """Find a scholarly identifier in the URL, else in the page's citation meta tags.""" + u = unquote(url) + host = urlparse(u).netloc.lower() + if m := _DOI.search(u): + return Identifier("doi", _clean_doi(m.group(1))) + if m := re.search(r"\b(PMC\d{4,9})\b", u): + return Identifier("pmcid", m.group(1)) + if "pubmed.ncbi.nlm.nih.gov" in host and (m := re.search(r"/(\d{5,9})\b", u)): + return Identifier("pmid", m.group(1)) + if m := re.search(r"/pii/(S?[0-9X]{15,17})", u, re.I): + return Identifier("pii", m.group(1).upper()) + if "arxiv.org" in host and (m := re.search(r"(\d{4}\.\d{4,5})", u)): + return Identifier("doi", f"10.48550/arXiv.{m.group(1)}") + if m := re.search(r"mdpi\.com/(\d{4}-\d{3}[\dX])/(\d+)/(\d+)/(\d+)", u): + return Identifier("mdpi", "/".join(m.groups())) + for name, content in _META.findall(html or ""): + if name.lower() == "citation_pmid" and content.strip().isdigit(): + return Identifier("pmid", content.strip()) + if (m := _DOI.search(content)) is not None: + return Identifier("doi", _clean_doi(m.group(1))) + return None + + +# ── resolve ────────────────────────────────────────────────────────────────── + +def lookup_work(ident: Identifier, client: httpx.Client) -> dict | None: + """The OpenAlex work for an identifier, or None.""" + if ident.kind == "pii": + r = client.get(f"{CROSSREF}/works", params={"filter": f"alternative-id:{ident.value}", "rows": 1}) + items = r.json().get("message", {}).get("items", []) if r.status_code == 200 else [] + if not items: + return None + ident = Identifier("doi", items[0]["DOI"]) + if ident.kind == "mdpi": + issn, vol, issue, art = ident.value.split("/") + r = client.get(f"{OPENALEX}/works", params={"filter": ( + f"primary_location.source.issn:{issn},biblio.volume:{vol}," + f"biblio.issue:{issue},biblio.first_page:{art}")}) + results = r.json().get("results", []) if r.status_code == 200 else [] + return results[0] if results else None + r = client.get(f"{OPENALEX}/works/{ident.kind}:{ident.value}") + return r.json() if r.status_code == 200 else None + + +def abstract_text(inverted_index: dict | None) -> str: + """OpenAlex stores abstracts as {word: [positions]}; rebuild the text.""" + if not inverted_index: + return "" + slots: dict[int, str] = {} + for word, positions in inverted_index.items(): + for p in positions: + slots[p] = word + return " ".join(slots[i] for i in sorted(slots)) + + +# ── full text ──────────────────────────────────────────────────────────────── + +def europe_pmc_text(pmcid: str, client: httpx.Client) -> tuple[str | None, str] | None: + """(title, body text) from Europe PMC's open-access XML, or None.""" + r = client.get(f"{EUROPE_PMC}/{pmcid}/fullTextXML") + if r.status_code != 200 or not r.text.lstrip().startswith("<"): + return None + try: + root = ET.fromstring(r.text) + except ET.ParseError: + return None + title_el = root.find(".//article-meta//article-title") + title = " ".join("".join(title_el.itertext()).split()) if title_el is not None else None + body = root.find(".//body") + if body is None: + return None + paragraphs = [] + for el in body.iter(): + if el.tag in ("title", "p"): + text = " ".join("".join(el.itertext()).split()) + if text: + paragraphs.append(text) + return title, "\n\n".join(paragraphs) + + +def pdf_text(url: str, client: httpx.Client) -> str | None: + """Text of an open-access PDF, or None if it's blocked or unreadable.""" + from pypdf import PdfReader + + try: + r = client.get(url) + except httpx.HTTPError: + return None + if r.status_code != 200 or not r.content.startswith(b"%PDF"): + return None + try: + reader = PdfReader(io.BytesIO(r.content)) + pages = [(p.extract_text() or "") for p in reader.pages[:MAX_PDF_PAGES]] + except Exception: + log.warning("Couldn't read PDF %s", url, exc_info=True) + return None + return clean_pdf_text("\n".join(pages)) + + +_REFERENCES = re.compile(r"^\s*(references|bibliography|literature cited)\s*$", re.I | re.M) + + +def clean_pdf_text(text: str) -> str: + """Rejoin hyphenated line breaks, collapse lines, drop the reference list.""" + text = re.sub(r"(\w)-\n(\w)", r"\1\2", text) + matches = list(_REFERENCES.finditer(text)) + if matches and matches[-1].start() > len(text) * 0.5: + text = text[: matches[-1].start()] + text = re.sub(r"[ \t]*\n[ \t]*", "\n", text) + # PDF line breaks are layout, not structure: join them; blank lines stay paragraph breaks. + text = re.sub(r"(? str: + words = text.split() + return text if len(words) <= MAX_WORDS else " ".join(words[:MAX_WORDS]) + + +def _date(work: dict) -> str | None: + d = work.get("publication_date") + return d if d and re.match(r"\d{4}-\d{2}-\d{2}$", d) else None + + +def recover(url: str, html: str | None = None, client: httpx.Client | None = None) -> Recovered | None: + """Best available text for a blocked academic page or a PDF link, or None.""" + ident = identify(url, html) + is_pdf = urlparse(url).path.lower().endswith(".pdf") + if ident is None and not is_pdf: + return None + own_client = client is None + client = client or new_client() + try: + found = _recover(ident, client) if ident else None + if found is None and is_pdf: + # The crawler doesn't open PDFs; read the file itself. + text = pdf_text(url, client) + if text and len(text.split()) >= 500: + return Recovered(None, _cap(text), None, "Read from the PDF") + return found + except (httpx.HTTPError, ValueError): + log.warning("Recovering %s failed", url, exc_info=True) + return None + finally: + if own_client: + client.close() + + +def _recover(ident: Identifier, client: httpx.Client) -> Recovered | None: + if ident.kind == "pmcid": + found = europe_pmc_text(ident.value, client) + if found and found[1]: + return Recovered(found[0], _cap(found[1]), None, "Full text from Europe PMC") + work = lookup_work(ident, client) + else: + work = lookup_work(ident, client) + pmcid = ((work or {}).get("ids") or {}).get("pmcid") + if pmcid: + found = europe_pmc_text(pmcid.rsplit("/", 1)[-1], client) + if found and found[1]: + return Recovered(found[0] or work.get("title"), _cap(found[1]), _date(work), + "Full text from Europe PMC") + if not work: + return None + + title = work.get("title") or work.get("display_name") + pdfs = [] + for loc in [work.get("best_oa_location") or {}, *(work.get("locations") or [])]: + if loc.get("is_oa") is not False and loc.get("pdf_url") and loc["pdf_url"] not in pdfs: + pdfs.append(loc["pdf_url"]) + for pdf_url in pdfs[:MAX_PDF_ATTEMPTS]: + text = pdf_text(pdf_url, client) + if text and len(text.split()) >= 500: + host = urlparse(pdf_url).netloc.removeprefix("www.") + return Recovered(title, _cap(text), _date(work), f"Open-access PDF from {host}") + + abstract = abstract_text(work.get("abstract_inverted_index")) + if len(abstract.split()) >= MIN_ABSTRACT_WORDS: + return Recovered(title, abstract, _date(work), "Abstract only: the full text isn't openly available") + return None diff --git a/backend/pipeline.py b/backend/pipeline.py index 0b1abc4..a413f89 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -18,6 +18,7 @@ import threading import time +from backend.config import settings from backend.db import add_event, connect, update_analysis log = logging.getLogger(__name__) @@ -27,8 +28,9 @@ # Progress bands (percent) for each stage. SEARCH = (0, 10) CRAWL = (10, 55) -EXTRACT = (55, 65) -CHUNK = (65, 68) +EXTRACT = (55, 62) +RECOVER = (62, 66) +CHUNK = (66, 68) EMBED = (68, 88) PROJECT = (88, 99) @@ -120,7 +122,7 @@ async def run_pipeline(ctx: RunContext, topic: str) -> None: try: urls = await asyncio.to_thread(_search, ctx, topic) crawled = await asyncio.to_thread(_crawl, ctx, urls) - ok_articles = await asyncio.to_thread(_extract_and_store, ctx, crawled) + ok_articles = await asyncio.to_thread(_extract_and_store, ctx, crawled, urls) await asyncio.to_thread(_chunk_and_store, ctx, ok_articles) await asyncio.to_thread(_embed_and_store, ctx) await asyncio.to_thread(_project, ctx) @@ -189,13 +191,17 @@ def on_page(url: str, ok: bool) -> None: return crawled -def _extract_and_store(ctx: RunContext, crawled: list[dict]) -> list[dict]: +def _extract_and_store(ctx: RunContext, crawled: list[dict], urls: list[str]) -> list[dict]: from backend.ingestion.extractor import SkipArticle, article_id, extract_article from backend.processing.metadata import extract_publish_date analysis_id = ctx.analysis_id ctx.stage("processing", EXTRACT, f"Extracting text from {len(crawled)} pages") ok_articles: list[dict] = [] + # Pages worth asking the scholarly indexes about: unreadable or too short + # (often a publisher's bot check), and pages the crawler couldn't open. + crawled_urls = {item["url"] for item in crawled} + unreadable: list[tuple[str, str | None]] = [(u, None) for u in urls if u not in crawled_urls] conn = connect() try: for i, item in enumerate(crawled, start=1): @@ -214,10 +220,13 @@ def _extract_and_store(ctx: RunContext, crawled: list[dict]) -> list[dict]: # their own connections and would wait on this transaction. conn.commit() ctx.event("skip", str(skip).capitalize(), url) + if not str(skip).startswith("not English"): + unreadable.append((url, html)) continue except Exception: log.warning("Extraction failed for %s", url, exc_info=True) ctx.event("skip", "Extraction failed", url) + unreadable.append((url, html)) continue conn.execute( @@ -240,11 +249,91 @@ def _extract_and_store(ctx: RunContext, crawled: list[dict]) -> list[dict]: ctx.progress(EXTRACT, i / len(crawled), f"Extracted {i} of {len(crawled)} pages") finally: conn.close() + if settings.recover_academic: + ok_articles += _recover_academic(ctx, unreadable) if not ok_articles: raise RuntimeError("No pages passed the quality filters — try a broader topic.") return _drop_duplicate_articles(ctx, ok_articles) +RECOVERY_WORKERS = 8 + + +def _recover_academic(ctx: RunContext, pages: list[tuple[str, str | None]]) -> list[dict]: + """ + Find open-access full text (or an abstract) for academic pages that came + back blocked or empty. Runs a few lookups at a time; cancellable between them. + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + from urllib.parse import urlparse + + from backend.ingestion import scholarly + from backend.ingestion.extractor import article_id + + candidates = [ + (url, html) for url, html in pages + if scholarly.identify(url, html) or urlparse(url).path.lower().endswith(".pdf") + ] + if not candidates: + return [] + ctx.stage("processing", RECOVER, + f"Looking for open-access copies of {len(candidates)} academic pages") + recovered: list[dict] = [] + client = scholarly.new_client() + try: + with ThreadPoolExecutor(max_workers=RECOVERY_WORKERS) as pool: + futures = { + pool.submit(scholarly.recover, url, html, client): url for url, html in candidates + } + for i, future in enumerate(as_completed(futures), start=1): + if ctx.cancelled(): + for f in futures: + f.cancel() + ctx.check() + url = futures[future] + found = future.result() + ctx.progress(RECOVER, i / len(candidates), + f"Checked {i} of {len(candidates)} academic pages") + if found is None: + continue + article = _store_recovered(ctx.analysis_id, article_id(ctx.analysis_id, url), + url, found) + recovered.append(article) + ctx.event("article", f"{article['title']} ({found.note.lower()})", url) + finally: + client.close() + ctx.check() + if recovered: + ctx.event("stage", f"Recovered {len(recovered)} academic articles from open sources") + return recovered + + +def _store_recovered(analysis_id: str, aid: str, url: str, found) -> dict: + from urllib.parse import urlparse + + domain = urlparse(url).netloc + title = found.title or domain + words = len(found.text.split()) + conn = connect() + try: + # The page may already be recorded as filtered; replace that row. + conn.execute( + "INSERT INTO articles (id, analysis_id, url, domain, title, publish_date, crawl_ts, " + " word_count, language, raw_text, status, source_note) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?) " + "ON CONFLICT(id) DO UPDATE SET domain=excluded.domain, title=excluded.title, " + " publish_date=excluded.publish_date, word_count=excluded.word_count, " + " language=excluded.language, raw_text=excluded.raw_text, status=excluded.status, " + " source_note=excluded.source_note", + (aid, analysis_id, url, domain, title, found.publish_date, int(time.time()), + words, "en", found.text, "ok", found.note), + ) + conn.commit() + finally: + conn.close() + return {"id": aid, "url": url, "raw_text": found.text, "title": title} + + def _drop_duplicate_articles(ctx: RunContext, articles: list[dict]) -> list[dict]: """Mark near-copies of an earlier article (e.g. syndicated stories) and drop them.""" from backend.processing.dedup import find_duplicate_articles diff --git a/frontend/openapi.json b/frontend/openapi.json index a5afe62..d71f7d1 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -830,6 +830,17 @@ "status": { "type": "string", "title": "Status" + }, + "source_note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Note" } }, "type": "object", diff --git a/frontend/src/lib/api/gen/types.gen.ts b/frontend/src/lib/api/gen/types.gen.ts index 6527695..540b557 100644 --- a/frontend/src/lib/api/gen/types.gen.ts +++ b/frontend/src/lib/api/gen/types.gen.ts @@ -134,6 +134,10 @@ export type Article = { * Status */ status: string; + /** + * Source Note + */ + source_note?: string | null; }; /** diff --git a/frontend/src/lib/components/ArticleSheet.svelte b/frontend/src/lib/components/ArticleSheet.svelte index 3f23c6e..f8839fd 100644 --- a/frontend/src/lib/components/ArticleSheet.svelte +++ b/frontend/src/lib/components/ArticleSheet.svelte @@ -3,6 +3,7 @@ import { api, type Article } from '#lib/api.ts'; import { formatPublishDate } from '#lib/format.ts'; import { useWorkspace } from '#lib/workspace.svelte.ts'; + import BookOpen from '@lucide/svelte/icons/book-open'; import ExternalLink from '@lucide/svelte/icons/external-link'; import X from '@lucide/svelte/icons/x'; @@ -76,6 +77,17 @@ {#if article.word_count}{article.word_count.toLocaleString()} words{/if} {/if} + {#if article?.source_note} +

    +

    + {/if} {#if error} diff --git a/pyproject.toml b/pyproject.toml index 79eb1d2..479e2c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "playwright>=1.45", "pyarrow>=15", "pydantic-settings>=2.4", + "pypdf>=5", "scikit-learn>=1.5", "tiktoken>=0.7", "trafilatura>=2.0", diff --git a/tests/test_migration.py b/tests/test_migration.py index dc2e443..f9bceb6 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -73,7 +73,7 @@ def test_corpus_becomes_latest_finished_job(tmp_path, monkeypatch): "article_id": "a1", "analysis_id": None, "x": 1.0, "y": 2.0, "cluster_id": 0, "distance": 0.5, } assert conn.execute("SELECT label FROM clusters").fetchone()[0] == "Label" - assert conn.execute("PRAGMA user_version").fetchone()[0] == 5 + assert conn.execute("PRAGMA user_version").fetchone()[0] == 6 tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} conn.close() assert not tables & {"jobs", "articles_v1", "projections"} diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index b91a3cc..f99468c 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -211,3 +211,50 @@ def test_progress_maps_into_band_and_throttles(isolated): def test_cancel_unknown_run_returns_false(): assert pipeline.cancel("not-running") is False + + +def test_blocked_academic_page_is_recovered(fakes, monkeypatch): + """A page that extracts too short but carries a DOI gets its open-access text.""" + from backend.ingestion import scholarly + + monkeypatch.setattr( + "backend.ingestion.search.collect_urls", + lambda topic, on_query=None: ["https://a.test/1", "https://a.test/2", + "https://doi.org/10.1234/blocked.short"], + ) + calls = [] + + def fake_recover(url, html, client): + calls.append(url) + return scholarly.Recovered("A recovered paper", prose(9), "2024-03-01", + "Open-access PDF from repository.test") + + monkeypatch.setattr(scholarly, "recover", fake_recover) + add_analysis("an1", status="queued") + _run("an1") + + assert calls == ["https://doi.org/10.1234/blocked.short"] + conn = connect() + row = dict(conn.execute( + "SELECT title, status, source_note, publish_date FROM articles WHERE url LIKE '%blocked%'" + ).fetchone()) + conn.close() + assert row == {"title": "A recovered paper", "status": "ok", + "source_note": "Open-access PDF from repository.test", "publish_date": "2024-03-01"} + assert _analysis("an1")["article_count"] == 3 + assert any(e["message"] == "Recovered 1 academic articles from open sources" for e in _events("an1")) + + +def test_recovery_can_be_turned_off(fakes, monkeypatch): + from backend.config import settings + from backend.ingestion import scholarly + + monkeypatch.setattr(settings, "recover_academic", False) + monkeypatch.setattr(scholarly, "recover", lambda *a: pytest.fail("recover called")) + monkeypatch.setattr( + "backend.ingestion.search.collect_urls", + lambda topic, on_query=None: ["https://a.test/1", "https://doi.org/10.1234/x.short"], + ) + add_analysis("an1", status="queued") + _run("an1") + assert _analysis("an1")["status"] == "done" diff --git a/tests/test_scholarly.py b/tests/test_scholarly.py new file mode 100644 index 0000000..e7487c2 --- /dev/null +++ b/tests/test_scholarly.py @@ -0,0 +1,173 @@ +import httpx +import pytest + +from backend.ingestion import scholarly +from backend.ingestion.scholarly import Identifier, identify + +# ── identify ───────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "url, expected", + [ + ("https://doi.org/10.1093/jsh/shaf109", ("doi", "10.1093/jsh/shaf109")), + ("https://agupubs.onlinelibrary.wiley.com/doi/10.1029/2024GL109634/full", + ("doi", "10.1029/2024GL109634")), + ("https://www.frontiersin.org/journals/x/articles/10.3389/fenvs.2026.1770260/xml", + ("doi", "10.3389/fenvs.2026.1770260")), + ("https://pmc.ncbi.nlm.nih.gov/articles/PMC6458494/", ("pmcid", "PMC6458494")), + ("https://pubmed.ncbi.nlm.nih.gov/31234567/", ("pmid", "31234567")), + ("https://www.sciencedirect.com/science/article/pii/S0360132322001234", + ("pii", "S0360132322001234")), + ("https://arxiv.org/abs/2404.14082", ("doi", "10.48550/arXiv.2404.14082")), + ("https://www.mdpi.com/2073-4433/16/7/791", ("mdpi", "2073-4433/16/7/791")), + ], +) +def test_identify_from_url(url, expected): + found = identify(url) + assert (found.kind, found.value) == expected + + +def test_identify_from_citation_meta_tags(): + html = '' + assert identify("https://example.org/article/123", html) == Identifier("doi", "10.1016/j.uclim.2023.101234") + html = '' + assert identify("https://example.org/a", html) == Identifier("pmid", "31234567") + + +def test_ordinary_pages_have_no_identifier(): + assert identify("https://www.nytimes.com/2024/07/01/climate/heat.html", "

    hi

    ") is None + + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def test_abstract_rebuilt_from_inverted_index(): + inv = {"Cities": [0], "trap": [1], "heat": [2, 5], "and": [3], "release": [4]} + assert scholarly.abstract_text(inv) == "Cities trap heat and release heat" + assert scholarly.abstract_text(None) == "" + + +def test_pdf_text_cleanup_joins_lines_and_drops_references(): + body = "Urban heat is meas-\nured by satellites.\nIt varies by block.\n\n" + "Text. " * 50 + text = scholarly.clean_pdf_text(body + "\nReferences\n1. Smith J. Heat. 2020.") + assert "measured by satellites. It varies by block." in text + assert "Smith" not in text + + +# ── recovery paths (stubbed web) ───────────────────────────────────────────── + +ARTICLE = " ".join(["cool roofs reduce urban heat"] * 150) # 750 words + + +def client_for(routes): + """A client whose requests are answered by `routes[(host, path-prefix)]`.""" + + def handler(request: httpx.Request) -> httpx.Response: + for (host, prefix), make in routes.items(): + if request.url.host == host and request.url.path.startswith(prefix): + return make(request) + return httpx.Response(404) + + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def work(**over): + return { + "title": "Cool roofs in hot cities", + "publication_date": "2024-05-02", + "ids": {}, + "best_oa_location": {"is_oa": True, "pdf_url": "https://journal.test/paper.pdf"}, + "locations": [], + "abstract_inverted_index": {w: [i] for i, w in enumerate("word" + str(i) for i in range(80))}, + **over, + } + + +def test_doi_with_open_access_pdf(monkeypatch): + monkeypatch.setattr(scholarly, "pdf_text", lambda url, client: ARTICLE) + client = client_for({("api.openalex.org", "/works/doi:"): lambda r: httpx.Response(200, json=work())}) + got = scholarly.recover("https://doi.org/10.1234/abc", client=client) + assert got.title == "Cool roofs in hot cities" + assert got.publish_date == "2024-05-02" + assert got.note == "Open-access PDF from journal.test" + assert len(got.text.split()) == 750 + + +def test_blocked_pdf_falls_back_to_abstract(monkeypatch): + monkeypatch.setattr(scholarly, "pdf_text", lambda url, client: None) + client = client_for({("api.openalex.org", "/works/doi:"): lambda r: httpx.Response(200, json=work())}) + got = scholarly.recover("https://doi.org/10.1234/abc", client=client) + assert got.note.startswith("Abstract only") + assert got.text.split()[:2] == ["word0", "word1"] + + +def test_tries_other_open_access_locations(monkeypatch): + tried = [] + + def fake_pdf(url, client): + tried.append(url) + return ARTICLE if "repository" in url else None + + monkeypatch.setattr(scholarly, "pdf_text", fake_pdf) + w = work(locations=[{"is_oa": True, "pdf_url": "https://repository.test/copy.pdf"}]) + client = client_for({("api.openalex.org", "/works/doi:"): lambda r: httpx.Response(200, json=w)}) + got = scholarly.recover("https://doi.org/10.1234/abc", client=client) + assert tried == ["https://journal.test/paper.pdf", "https://repository.test/copy.pdf"] + assert got.note == "Open-access PDF from repository.test" + + +PMC_XML = """
    +Heat and health +Introduction

    Heat kills more people than floods.

    +

    Cities are hotter.

    Smith 2020
    """ + + +def test_pmc_page_uses_europe_pmc_full_text(): + client = client_for({ + ("www.ebi.ac.uk", "/europepmc/webservices/rest/PMC1234567/fullTextXML"): + lambda r: httpx.Response(200, text=PMC_XML), + }) + got = scholarly.recover("https://pmc.ncbi.nlm.nih.gov/articles/PMC1234567/", client=client) + assert got.title == "Heat and health" + assert got.text == "Introduction\n\nHeat kills more people than floods.\n\nCities are hotter." + assert got.note == "Full text from Europe PMC" + + +def test_elsevier_pii_resolved_through_crossref(monkeypatch): + monkeypatch.setattr(scholarly, "pdf_text", lambda url, client: ARTICLE) + seen = [] + + def openalex(request): + seen.append(request.url.path) + return httpx.Response(200, json=work()) + + client = client_for({ + ("api.crossref.org", "/works"): lambda r: httpx.Response( + 200, json={"message": {"items": [{"DOI": "10.1016/j.x.2024.1"}]}}), + ("api.openalex.org", "/works/doi:"): openalex, + }) + got = scholarly.recover("https://www.sciencedirect.com/science/article/pii/S0360132322001234", + client=client) + assert seen == ["/works/doi:10.1016/j.x.2024.1"] + assert got is not None + + +def test_unknown_work_and_unidentifiable_pages_recover_nothing(): + client = client_for({}) + assert scholarly.recover("https://doi.org/10.1234/missing", client=client) is None + assert scholarly.recover("https://blog.test/post", client=client) is None + + +def test_network_errors_are_contained(): + def boom(request): + raise httpx.ConnectError("down") + + client = httpx.Client(transport=httpx.MockTransport(boom)) + assert scholarly.recover("https://doi.org/10.1234/abc", client=client) is None + + +def test_plain_pdf_links_are_read_directly(monkeypatch): + monkeypatch.setattr(scholarly, "pdf_text", lambda url, client: ARTICLE) + got = scholarly.recover("https://city.gov/reports/heat-plan.pdf", client=client_for({})) + assert (got.note, got.title) == ("Read from the PDF", None) diff --git a/uv.lock b/uv.lock index 028ff4c..899236a 100644 --- a/uv.lock +++ b/uv.lock @@ -459,6 +459,7 @@ dependencies = [ { name = "playwright" }, { name = "pyarrow" }, { name = "pydantic-settings" }, + { name = "pypdf" }, { name = "scikit-learn" }, { name = "tiktoken" }, { name = "trafilatura" }, @@ -492,6 +493,7 @@ requires-dist = [ { name = "playwright", specifier = ">=1.45" }, { name = "pyarrow", specifier = ">=15" }, { name = "pydantic-settings", specifier = ">=2.4" }, + { name = "pypdf", specifier = ">=5" }, { name = "scikit-learn", specifier = ">=1.5" }, { name = "tiktoken", specifier = ">=0.7" }, { name = "trafilatura", specifier = ">=2.0" }, @@ -1647,6 +1649,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" }, ] +[[package]] +name = "pypdf" +version = "6.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/c1/da25a099164cf4b210d63b957c902ad687139f4b8c12c20aec7953a4a266/pypdf-6.20.1.tar.gz", hash = "sha256:28f5a9d2fdc2749264612d94e6a58de54c11d730d9f0cabf8ad34117c4942b45", size = 7075352, upload-time = "2026-10-12T16:14:24.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/f8/4cbd09988b4b158260b7e0df38bf16f19e998bf0e257a18661a8da04280e/pypdf-6.20.1-py3-none-any.whl", hash = "sha256:aa5a55ddcffdc5e5ab291d5decb23f6383f4e56f8e3263dc39af41fff03885ad", size = 402665, upload-time = "2026-10-12T16:14:22.556Z" }, +] + [[package]] name = "pytest" version = "9.1.1" From 850ff2f3ecc92a1046aba8298154624c7c8439aa Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Tue, 13 Oct 2026 23:29:43 -0400 Subject: [PATCH 12/16] Add a demo map, end-to-end tests and CI - scripts/seed_demo.py builds a small synthetic map (articles, topics with terms and summaries, activity log, a saved conversation) with no network or API keys: for trying the app, and as end-to-end test data - Playwright tests (frontend/e2e) drive the real backend serving the built app, seeded into a throwaway .e2e/ folder: home, map and key, topic selection across views, table and reader, timeline, key terms, saved chat with citations, theme, not-found - GitHub Actions: backend (uv, ruff, pytest), frontend (svelte-check, eslint, vitest, build), then e2e. npm pinned to 11 (npm 10's resolver crashes on this dependency tree) - .gitattributes keeps LF line endings across Windows and Linux Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 4 + .github/workflows/ci.yml | 75 +++++++++++++++++ .gitignore | 1 + README.md | 20 ++++- frontend/e2e/app.spec.ts | 115 +++++++++++++++++++++++++ frontend/package-lock.json | 46 ++++++++++ frontend/package.json | 6 +- frontend/playwright.config.ts | 45 ++++++++++ frontend/tsconfig.json | 2 +- scripts/seed_demo.py | 154 ++++++++++++++++++++++++++++++++++ 10 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 frontend/e2e/app.spec.ts create mode 100644 frontend/playwright.config.ts create mode 100644 scripts/seed_demo.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e70565d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Keep line endings consistent across Windows and Linux (CI, tooling) +* text=auto eol=lf +*.png binary +*.svg text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..21b32d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend (lint, tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: uv python install 3.12 + - run: uv sync --locked + - run: uv run ruff check backend tests scripts + - run: uv run pytest -q + + frontend: + name: Frontend (types, lint, unit tests, build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + # npm 10's resolver crashes on this dependency tree; npm 11 is fine + - run: npm install -g npm@11 + - run: npm ci + - run: npm run check + - run: npm run lint + - run: npx vitest run + - run: npm run build + + e2e: + name: End-to-end (demo map, no API keys) + runs-on: ubuntu-latest + needs: [backend, frontend] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: uv python install 3.12 + - run: uv sync --locked + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - working-directory: frontend + run: | + npm install -g npm@11 + npm ci + npx playwright install --with-deps chromium + npm run build + npx playwright test + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-traces + path: frontend/test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 4ab2dc9..a6200ef 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ frontend/build/ .claude/ .venv/ .playwright-mcp/ +.e2e/ diff --git a/README.md b/README.md index 7b983a0..a31f08c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,12 @@ uv run uvicorn backend.api.main:app --port 8001 Then open [http://localhost:8001](http://localhost:8001). The backend serves the built app and the API from one address. +**No API keys yet?** Load a small synthetic demo map to explore the interface: + +```bash +uv run python -m scripts.seed_demo +``` + ### Working on the frontend Run the backend as above, plus the Vite dev server with hot reload (it forwards `/api` to port 8001): @@ -87,7 +93,19 @@ How answers are found: `backend/synthesis/chat.py` (hybrid search: vector simila How maps are built: `backend/topics.py` (article vectors → UMAP → HDBSCAN → c-TF-IDF, BERTopic's recipe without PyTorch) and `backend/embedding/project.py` (naming topics with Claude). -Checks: `uv run pytest`, `uv run ruff check backend tests`, and in `frontend/`: `npm run check`, `npx vitest run`, `npm run lint`. +Checks (CI runs all of these on every push and pull request): + +```bash +uv run ruff check backend tests scripts +uv run pytest +cd frontend && npm run check && npm run lint && npx vitest run +``` + +End-to-end tests drive the real app (backend + built frontend) against the demo map in a throwaway `.e2e/` folder, with no network or API keys: + +```bash +cd frontend && npm run build && npx playwright install chromium && npm run test:e2e +``` --- diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts new file mode 100644 index 0000000..feb516f --- /dev/null +++ b/frontend/e2e/app.spec.ts @@ -0,0 +1,115 @@ +import { expect, test, type Page } from '@playwright/test'; + +// The synthetic map from scripts/seed_demo.py +const DEMO = '/analysis/demo-0000-0000-0000-000000000001'; +const TOPIC = 'Demo: urban heat and city design'; + +async function openDemo(page: Page, view = '') { + await page.goto(`${DEMO}${view}`); + await expect(page.getByRole('heading', { level: 1, name: TOPIC })).toBeVisible(); +} + +test('home lists the demo map and keeps the start button disabled until a topic is typed', async ({ + page +}) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Map a conversation.' })).toBeVisible(); + await expect(page.getByRole('link', { name: TOPIC })).toBeVisible(); + await expect(page.getByText('12 articles')).toBeVisible(); + + const start = page.getByRole('button', { name: 'Map this topic' }); + await expect(start).toBeDisabled(); + await page.getByRole('button', { name: 'urban heat islands and city design' }).click(); + await expect(page.getByLabel('Topic')).toHaveValue('urban heat islands and city design'); + await expect(start).toBeEnabled(); +}); + +test('map shows every article, topic labels and the cluster key', async ({ page }) => { + await openDemo(page); + await expect(page.getByText('12 articles in 4 clusters')).toBeVisible(); + const map = page.getByRole('img', { name: /Map of 12 articles/ }); + await expect(map.locator('circle[stroke="var(--paper)"]')).toHaveCount(12); + for (const label of ['Reflective surfaces', 'Trees and green space', 'Heat and inequality']) { + await expect(map.getByText(label)).toBeVisible(); + } + await expect( + page.getByRole('complementary', { name: 'Clusters' }).getByText('Unclustered') + ).toBeVisible(); +}); + +test('selecting a topic focuses every view and shows its summary', async ({ page }) => { + await openDemo(page); + const key = page.getByRole('complementary', { name: 'Clusters' }); + await key.getByRole('button', { name: /Trees and green space/ }).click(); + await expect(page).toHaveURL(/\?cluster=1/); + await expect(key.getByText('canopy, parks and the upkeep', { exact: false })).toBeVisible(); + + // The selection travels with the view links. + await page.getByRole('link', { name: 'Outliers' }).click(); + await expect(page).toHaveURL(/\/outliers\?cluster=1/); + await expect(page.getByRole('main').getByRole('button')).toHaveCount(4); + await expect(page.getByRole('main').getByText('Watering trees in drought summers')).toBeVisible(); + + await key.getByRole('button', { name: 'Show all' }).click(); + await expect(page).not.toHaveURL(/cluster=/); +}); + +test('table view lists articles and opens the reader, which closes with Escape', async ({ + page +}) => { + await openDemo(page); + await page.getByRole('button', { name: 'Table' }).click(); + await page.getByRole('button', { name: 'Measuring albedo on city blocks' }).click(); + + const reader = page.getByRole('dialog'); + await expect( + reader.getByRole('heading', { name: 'Measuring albedo on city blocks' }) + ).toBeVisible(); + await expect(reader.getByText(/Abstract only/)).toBeVisible(); + await expect(page).toHaveURL(/article=demo-article-02/); + + await page.keyboard.press('Escape'); + await expect(reader).toBeHidden(); + await expect(page).not.toHaveURL(/article=/); +}); + +test('timeline draws a bar for each dated year', async ({ page }) => { + await openDemo(page, '/timeline'); + await expect(page.getByText('12 articles have a publish date')).toBeVisible(); + await expect(page.getByRole('img', { name: /per year/ })).toBeVisible(); +}); + +test('key terms show each topic with its summary and terms', async ({ page }) => { + await openDemo(page, '/terms'); + const main = page.getByRole('main'); + await expect(main.getByRole('heading', { name: /Heat and inequality/ })).toBeVisible(); + await expect(main.getByText('Who is most exposed', { exact: false })).toBeVisible(); + await expect(main.getByText('redlining')).toBeVisible(); +}); + +test('saved conversation renders with clickable citations', async ({ page }) => { + await openDemo(page, '/ask'); + await expect(page.getByText('Do cool roofs or trees cool cities more?')).toBeVisible(); + const cite = page.getByRole('button', { name: /Source 2:/ }); + await expect(cite).toBeVisible(); + await expect(page.getByText('Also searched, not cited (1)')).toBeVisible(); + + await cite.click(); + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'Street trees and shade equity' }) + ).toBeVisible(); +}); + +test('theme toggle switches to dark and remembers it', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Switch to dark theme' }).click(); + await expect(page.locator('html')).toHaveClass(/dark/); + await page.reload(); + await expect(page.locator('html')).toHaveClass(/dark/); +}); + +test('an unknown map shows a helpful not-found page', async ({ page }) => { + await page.goto('/analysis/does-not-exist'); + await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Back to all maps' })).toBeVisible(); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e22c0e..aaf49ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "^0.99.0", + "@playwright/test": "1.63", "@sveltejs/adapter-static": "^4.0.0", "@sveltejs/kit": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^7.2.0", @@ -544,6 +545,22 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -4162,6 +4179,35 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", diff --git a/frontend/package.json b/frontend/package.json index 329633e..f20123c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,12 +13,14 @@ "lint": "prettier --check . && eslint .", "format": "prettier --write .", "test:unit": "vitest", - "test": "vitest run", - "gen:api": "openapi-ts" + "test": "vitest run && playwright test", + "gen:api": "openapi-ts", + "test:e2e": "playwright test" }, "devDependencies": { "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "^0.99.0", + "@playwright/test": "1.63", "@sveltejs/adapter-static": "^4.0.0", "@sveltejs/kit": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^7.2.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..532cd91 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'node:path'; + +/** + * End-to-end tests against the real backend serving the built app, seeded with + * the synthetic demo map (scripts/seed_demo.py) in a throwaway directory — + * no crawling, no API keys. Run `npm run build` first. + */ +const ROOT = path.resolve(import.meta.dirname, '..'); +const DATA = path.join(ROOT, '.e2e'); +const PYTHON = path.join( + ROOT, + '.venv', + process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python' +); +const PORT = 8011; + +export default defineConfig({ + testDir: 'e2e', + fullyParallel: false, // one shared database + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['list']] : 'list', + use: { + baseURL: `http://localhost:${PORT}`, + trace: 'retain-on-failure' + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: `"${PYTHON}" -m scripts.seed_demo --reset && "${PYTHON}" -m uvicorn backend.api.main:app --port ${PORT}`, + cwd: ROOT, + url: `http://localhost:${PORT}/api/health`, + reuseExistingServer: false, + timeout: 120_000, + env: { + DB_PATH: path.join(DATA, 'e2e.db'), + LANCE_PATH: path.join(DATA, 'vectors'), + // Nothing in these tests may reach the network. + RECOVER_ACADEMIC: 'false', + BRAVE_API_KEY: '', + ANTHROPIC_API_KEY: 'e2e-not-a-real-key', + OPENAI_API_KEY: 'e2e-not-a-real-key' + } + } +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 825bd75..2c1e028 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -4,5 +4,5 @@ "strict": true, "types": ["$app/types", "node"] }, - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "playwright.config.ts", "e2e"] } diff --git a/scripts/seed_demo.py b/scripts/seed_demo.py new file mode 100644 index 0000000..6cc4ae4 --- /dev/null +++ b/scripts/seed_demo.py @@ -0,0 +1,154 @@ +""" +Create a small, fully synthetic demo map — no crawling, no API keys. + +Used by the end-to-end tests, and handy for trying the interface before +setting up search, embedding and Claude keys. + + uv run python -m scripts.seed_demo # into the configured database + DB_PATH=/tmp/e2e.db LANCE_PATH=/tmp/e2e-vectors uv run python -m scripts.seed_demo --reset +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import time +from pathlib import Path + +from backend.config import settings +from backend.db import connect, init_db +from backend.embedding import store + +DEMO_ID = "demo-0000-0000-0000-000000000001" +DEMO_TOPIC = "Demo: urban heat and city design" + +# (cluster, title, domain, publish date, x, y, distance) +ARTICLES = [ + (0, "Cool roofs lower rooftop temperatures", "roofs.example", "2023-06", 1.0, 1.2, 0.08), + (0, "White paint versus green roofs", "roofs.example", "2024-02", 1.3, 0.9, 0.10), + (0, "Measuring albedo on city blocks", "albedo.example", "2024-07", 0.8, 1.5, 0.12), + (0, "Reflective pavements in practice", "streets.example", "2025-01", 1.6, 1.4, 0.31), + (1, "Street trees and shade equity", "canopy.example", "2022-08", -1.2, 0.4, 0.09), + (1, "Canopy cover maps for planners", "canopy.example", "2023-03", -1.0, 0.1, 0.11), + (1, "Parks as cool islands", "parks.example", "2024-05", -1.5, 0.7, 0.14), + (1, "Watering trees in drought summers", "parks.example", "2025-04", -0.7, -0.1, 0.27), + (2, "Who lives in the hottest neighbourhoods", "justice.example", "2021-07", 0.2, -1.4, 0.07), + (2, "Heat, redlining and health", "justice.example", "2023-09", 0.5, -1.1, 0.12), + (2, "Cooling centres and outreach", "health.example", "2024-08", -0.1, -1.7, 0.18), + (-1, "A history of urban climatology", "history.example", "2019-05", 2.4, -0.6, 0.45), +] + +CLUSTERS = [ + (0, "Reflective surfaces", "Articles on cool roofs, albedo and reflective pavements as a fast way to cut surface heat.", + [["cool roofs", 0.31], ["albedo", 0.24], ["reflective", 0.19], ["pavement", 0.12]]), + (1, "Trees and green space", "Articles on canopy, parks and the upkeep that makes trees an effective cooling strategy.", + [["canopy", 0.33], ["shade", 0.22], ["parks", 0.18], ["drought", 0.09]]), + (2, "Heat and inequality", "Articles on who is most exposed to urban heat and how cities respond.", + [["redlining", 0.29], ["exposure", 0.21], ["health", 0.17], ["outreach", 0.08]]), +] + + +def body(title: str, n: int) -> str: + """Placeholder article text (synthetic; long enough to read as an article).""" + lines = [f"{title}. This is demo text for Discourse Lens, not a real article."] + lines += [f"Paragraph {i + 1} discusses {title.lower()} in a fictional city, " + "with figures invented for the demo." for i in range(n)] + return "\n\n".join(lines) + + +def seed(reset: bool = False) -> str: + if reset: + Path(settings.db_path).unlink(missing_ok=True) + for suffix in ("-wal", "-shm"): + Path(settings.db_path + suffix).unlink(missing_ok=True) + shutil.rmtree(settings.lance_path, ignore_errors=True) + init_db() + now = int(time.time()) + conn = connect() + try: + conn.execute("DELETE FROM analyses WHERE id = ?", (DEMO_ID,)) + conn.execute( + "INSERT INTO analyses (id, topic, status, progress, stage_detail, article_count, " + "created_ts, updated_ts) VALUES (?,?,?,?,?,?,?,?)", + (DEMO_ID, DEMO_TOPIC, "done", 100, f"Indexed {len(ARTICLES)} articles", + len(ARTICLES), now, now), + ) + vectors = [] + for i, (cluster, title, domain, date, x, y, dist) in enumerate(ARTICLES): + aid = f"demo-article-{i:02d}" + text = body(title, 6) + conn.execute( + "INSERT INTO articles (id, analysis_id, url, domain, title, publish_date, crawl_ts, " + "word_count, language, raw_text, status, source_note) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (aid, DEMO_ID, f"https://{domain}/{aid}", domain, title, date, now, + len(text.split()), "en", text, "ok", + "Abstract only: the full text isn't openly available" if i == 2 else None), + ) + conn.execute( + "INSERT INTO chunks (id, analysis_id, article_id, parent_id, parent_text, child_text, " + "chunk_index, token_count, is_duplicate) VALUES (?,?,?,?,?,?,?,?,0)", + (f"{aid}#p0c0", DEMO_ID, aid, f"{aid}#p0", text, text, 0, len(text.split())), + ) + conn.execute( + "INSERT INTO placements (article_id, analysis_id, x, y, cluster_id, distance) " + "VALUES (?,?,?,?,?,?)", + (aid, DEMO_ID, x, y, cluster, dist), + ) + vectors.append({"chunk_id": f"{aid}#p0c0", "article_id": aid, "child_text": text, + "domain": domain, "publish_date": date, + "vector": [float(cluster == k) for k in (0, 1, 2)] + [x, y]}) + for cid, label, description, terms in CLUSTERS: + size = sum(1 for a in ARTICLES if a[0] == cid) + conn.execute( + "INSERT INTO clusters (analysis_id, cluster_id, label, description, article_count, terms) " + "VALUES (?,?,?,?,?,?)", + (DEMO_ID, cid, label, description, size, json.dumps(terms)), + ) + for kind, message in [("stage", "Crawling 14 pages"), ("article", ARTICLES[0][1]), + ("skip", "Too short (40 words)"), ("stage", "Done")]: + conn.execute( + "INSERT INTO analysis_events (analysis_id, ts, kind, message) VALUES (?,?,?,?)", + (DEMO_ID, now, kind, message), + ) + sources = [ + {"n": 1, "chunk_id": "demo-article-00#p0c0", "article_id": "demo-article-00", + "url": "https://roofs.example/demo-article-00", "title": ARTICLES[0][1], + "domain": "roofs.example", "publish_date": "2023-06", "cited": True}, + {"n": 2, "chunk_id": "demo-article-04#p0c0", "article_id": "demo-article-04", + "url": "https://canopy.example/demo-article-04", "title": ARTICLES[4][1], + "domain": "canopy.example", "publish_date": "2022-08", "cited": True}, + {"n": 3, "chunk_id": "demo-article-08#p0c0", "article_id": "demo-article-08", + "url": "https://justice.example/demo-article-08", "title": ARTICLES[8][1], + "domain": "justice.example", "publish_date": "2021-07", "cited": False}, + ] + conn.execute( + "INSERT INTO chat_messages (analysis_id, role, text, sources, created_ts) " + "VALUES (?, 'user', ?, NULL, ?)", + (DEMO_ID, "Do cool roofs or trees cool cities more?", now), + ) + conn.execute( + "INSERT INTO chat_messages (analysis_id, role, text, sources, created_ts) " + "VALUES (?, 'assistant', ?, ?, ?)", + (DEMO_ID, + "The demo articles disagree. Cool roofs act fastest on surface heat [1], while " + "trees also bring shade along streets [2].", json.dumps(sources), now), + ) + conn.commit() + finally: + conn.close() + store.drop_embeddings(DEMO_ID) + store.write_embeddings(DEMO_ID, vectors) + return DEMO_ID + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[1]) + parser.add_argument("--reset", action="store_true", + help="delete the database and vectors first (only for throwaway paths)") + args = parser.parse_args() + print(f"Seeded demo map {seed(args.reset)} into {settings.db_path}") + + +if __name__ == "__main__": + main() From eea206b8e4d63f29efdfaa1d06101e0876a2e692 Mon Sep 17 00:00:00 2001 From: Meerav Shah Date: Tue, 13 Oct 2026 23:47:41 -0400 Subject: [PATCH 13/16] Optional source review before crawling; SearXNG search provider - "Let me check the sources before they're read" pauses a run after searching: edit and re-run the searches, untick pages, add your own, then approve (new reviewing status, /plan endpoints, schema v7) - SEARCH_PROVIDER=searxng with SEARXNG_URL uses a self-hosted SearXNG instance instead of Brave Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + README.md | 4 +- backend/api/main.py | 95 ++++++- backend/config.py | 4 +- backend/db/__init__.py | 5 +- backend/db/schema.sql | 5 +- backend/ingestion/search.py | 137 +++++++--- backend/pipeline.py | 96 ++++++- frontend/openapi.json | 233 +++++++++++++++++ frontend/src/lib/api.ts | 11 +- frontend/src/lib/api/gen/index.ts | 4 +- frontend/src/lib/api/gen/sdk.gen.ts | 35 ++- frontend/src/lib/api/gen/types.gen.ts | 154 ++++++++++- frontend/src/lib/api/stream.ts | 4 +- .../src/lib/components/ProgressView.svelte | 155 +++++------ .../src/lib/components/ReviewPanel.svelte | 242 ++++++++++++++++++ .../src/lib/components/StatusLabel.svelte | 4 + frontend/src/routes/+page.svelte | 7 +- tests/test_analyses_api.py | 3 +- tests/test_migration.py | 2 +- tests/test_review.py | 123 +++++++++ tests/test_search.py | 58 +++++ 22 files changed, 1246 insertions(+), 137 deletions(-) create mode 100644 frontend/src/lib/components/ReviewPanel.svelte create mode 100644 tests/test_review.py create mode 100644 tests/test_search.py diff --git a/.env.example b/.env.example index b2380ee..e7e384d 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,5 @@ KMEANS_K=auto # auto | integer (e.g. 7) RAG_TOP_K=15 CHAT_EFFORT=medium # low | medium | high: reasoning before chat answers RECOVER_ACADEMIC=true # look up open-access copies of pages academic publishers block +SEARCH_PROVIDER=brave # brave | searxng +SEARXNG_URL= # e.g. http://localhost:8888 when SEARCH_PROVIDER=searxng diff --git a/README.md b/README.md index a31f08c..aa45d9e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Discourse Lens takes a topic, reads ~100 relevant articles from the web, and map - Python 3.12 and [uv](https://docs.astral.sh/uv/) - Node.js 20.19+ (Vite 8) - [Ollama](https://ollama.com) (for local embeddings) — or an OpenAI API key -- A [Brave Search API key](https://brave.com/search/api) (free tier: 2000 queries/month) +- A [Brave Search API key](https://brave.com/search/api) (free tier: 2000 queries/month), or a self-hosted [SearXNG](https://docs.searxng.org) instance with the JSON format enabled (`SEARCH_PROVIDER=searxng`, `SEARXNG_URL=http://localhost:8888`) - An [Anthropic API key](https://console.anthropic.com) (for chat and cluster labelling) --- @@ -111,7 +111,7 @@ cd frontend && npm run build && npx playwright install chromium && npm run test: ## Using it -1. On the home page, type a topic (e.g. `mechanistic interpretability in large language models`) and choose **Map this topic**. +1. On the home page, type a topic (e.g. `mechanistic interpretability in large language models`) and choose **Map this topic**. Tick **Let me check the sources before they're read** to see the searches and pages first: edit the searches, untick pages, or add your own before anything is read. 2. A map takes **3–8 minutes**. Academic publishers often block automated reading; for those pages (and for PDF links) Discourse Lens looks up an open-access copy or the abstract through OpenAlex and Europe PMC, and the article reader says where the text came from. You'll see each step as it happens: the search queries, pages crawled, articles kept or skipped (and why), and clusters as they're named. You can leave the page, or **Cancel this map**. 3. When it's done you get five views of the same map: - **Map**: articles that make similar arguments sit close together, grouped into topics (camps) with contour lines and a name for each. Switch to **Table** for a plain list. diff --git a/backend/api/main.py b/backend/api/main.py index 5840e1f..c7c75e9 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -54,12 +54,37 @@ async def lifespan(_app: FastAPI): # --------------------------------------------------------------------------- AnalysisStatus = Literal[ - "queued", "crawling", "processing", "embedding", "projecting", "done", "error", "cancelled" + "queued", "crawling", "reviewing", "processing", "embedding", "projecting", "done", "error", + "cancelled", ] class AnalysisCreate(BaseModel): topic: str = Field(min_length=1, max_length=500) + # Pause after searching so the user can edit queries and choose pages. + review: bool = False + + +class PlanResult(BaseModel): + url: str + title: str + snippet: str + query: str + + +class Plan(BaseModel): + """What a paused analysis found: its queries and the pages they returned.""" + + queries: list[str] + results: list[PlanResult] + + +class PlanSearch(BaseModel): + queries: list[str] = Field(min_length=1, max_length=12) + + +class PlanApprove(BaseModel): + urls: list[str] = Field(min_length=1) class Analysis(BaseModel): @@ -213,7 +238,7 @@ async def create_analysis(req: AnalysisCreate) -> Analysis: row = _get_analysis(conn, analysis_id) finally: conn.close() - pipeline.start(analysis_id, topic) + pipeline.start(analysis_id, topic, review=req.review) return Analysis(**dict(row)) @@ -264,6 +289,72 @@ def cancel_analysis(analysis_id: str) -> Analysis: return Analysis(**dict(row)) +def _awaiting_review(analysis_id: str) -> pipeline.RunContext: + conn = connect() + try: + _get_analysis(conn, analysis_id) + finally: + conn.close() + ctx = pipeline.context(analysis_id) + if ctx is None or not ctx.awaiting_review(): + raise HTTPException(status_code=409, detail="This analysis isn't waiting for review") + return ctx + + +@app.get("/api/analyses/{analysis_id}/plan") +def get_plan(analysis_id: str) -> Plan: + conn = connect() + try: + _get_analysis(conn, analysis_id) + finally: + conn.close() + plan = pipeline.load_plan(analysis_id) + if plan is None: + raise HTTPException(status_code=404, detail="This analysis has no search plan") + return Plan(**plan) + + +@app.post("/api/analyses/{analysis_id}/plan/search") +def search_plan(analysis_id: str, req: PlanSearch) -> Plan: + """Run edited queries again while the analysis waits for review.""" + from backend.ingestion.search import search_all + + _awaiting_review(analysis_id) + queries = [q.strip() for q in req.queries if q.strip()] + if not queries: + raise HTTPException(status_code=422, detail="Give at least one search query") + results = search_all(queries) + plan = pipeline.save_plan(analysis_id, queries, results) + from backend.db import add_event, update_analysis + + update_analysis(analysis_id, stage_detail=f"Waiting for you to review {len(results)} pages") + add_event(analysis_id, "stage", f"Searched again: {len(results)} pages") + return Plan(**plan) + + +@app.post("/api/analyses/{analysis_id}/plan/approve", status_code=202) +def approve_plan(analysis_id: str, req: PlanApprove) -> Analysis: + """Continue the paused analysis with the chosen pages (search results or your own URLs).""" + from urllib.parse import urlparse + + ctx = _awaiting_review(analysis_id) + urls = list(dict.fromkeys(u.strip() for u in req.urls if u.strip())) + bad = [u for u in urls if urlparse(u).scheme not in ("http", "https") or not urlparse(u).netloc] + if bad: + raise HTTPException(status_code=422, detail=f"Not a web address: {bad[0]}") + if len(urls) > settings.max_articles: + raise HTTPException( + status_code=422, + detail=f"Choose at most {settings.max_articles} pages (you chose {len(urls)})", + ) + ctx.approve(urls) + conn = connect() + try: + return Analysis(**dict(_get_analysis(conn, analysis_id))) + finally: + conn.close() + + @app.get("/api/analyses/{analysis_id}/events") def get_events(analysis_id: str, after: int = 0) -> list[AnalysisEvent]: conn = connect() diff --git a/backend/config.py b/backend/config.py index c809003..bf382a9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -15,8 +15,10 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") - # Search + # Search: brave (needs BRAVE_API_KEY) or searxng (self-hosted, needs SEARXNG_URL) + search_provider: Literal["brave", "searxng"] = "brave" brave_api_key: str = "" + searxng_url: str = "" max_articles: int = 120 # Look up open-access copies of academic pages the crawler can't read (OpenAlex, Europe PMC) diff --git a/backend/db/__init__.py b/backend/db/__init__.py index 268f61b..3b156b3 100644 --- a/backend/db/__init__.py +++ b/backend/db/__init__.py @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) -SCHEMA_VERSION = 6 +SCHEMA_VERSION = 7 _SCHEMA = Path(__file__).parent / "schema.sql" # Chunk-level map rows used by schema v2/v3, before placements + clusters. @@ -30,7 +30,7 @@ """ # Analyses still in one of these states are being worked on by a pipeline. -RUNNING_STATUSES = ("queued", "crawling", "processing", "embedding", "projecting") +RUNNING_STATUSES = ("queued", "crawling", "reviewing", "processing", "embedding", "projecting") def connect(db_path: str | None = None) -> sqlite3.Connection: @@ -58,6 +58,7 @@ def init_db(db_path: str | None = None) -> None: conn.executescript(_SCHEMA.read_text()) _ensure_column(conn, "analyses", "stage_detail", "TEXT") _ensure_column(conn, "articles", "source_note", "TEXT") # v5 -> v6 + _ensure_column(conn, "analyses", "plan", "TEXT") # v6 -> v7: review step if _table_exists(conn, "projections"): _migrate_projections(conn) conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") diff --git a/backend/db/schema.sql b/backend/db/schema.sql index a19678f..f7d6a34 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -1,4 +1,4 @@ --- Schema version 6 (PRAGMA user_version = 6): every row belongs to an analysis. +-- Schema version 7 (PRAGMA user_version = 7): every row belongs to an analysis. -- Deleting an analysis cascades to everything it collected -- (requires PRAGMA foreign_keys = ON, set by backend.db.connect). @@ -6,9 +6,10 @@ CREATE TABLE IF NOT EXISTS analyses ( id TEXT PRIMARY KEY, -- uuid4 topic TEXT NOT NULL, - status TEXT NOT NULL, -- queued | crawling | processing | embedding | projecting | done | error | cancelled + status TEXT NOT NULL, -- queued | crawling | reviewing | processing | embedding | projecting | done | error | cancelled progress INTEGER DEFAULT 0, -- 0-100 stage_detail TEXT, -- e.g. "Crawled 47 of 112 pages" + plan TEXT, -- review step: JSON {queries, results[{url,title,snippet,query}]} article_count INTEGER DEFAULT 0, error_msg TEXT, warning TEXT, -- non-fatal warning, e.g. low article yield diff --git a/backend/ingestion/search.py b/backend/ingestion/search.py index 270940f..436fdeb 100644 --- a/backend/ingestion/search.py +++ b/backend/ingestion/search.py @@ -1,6 +1,18 @@ +""" +Turn a topic into candidate pages: Claude writes search queries, a search +provider runs them, and results are deduplicated up to the article cap. + +Providers (SEARCH_PROVIDER): "brave" (Brave Search API, needs BRAVE_API_KEY) +or "searxng" (a SearXNG instance with the JSON format enabled, at SEARXNG_URL; +self-hostable, no key). +""" + +from __future__ import annotations + import logging import time from collections.abc import Callable +from dataclasses import dataclass import httpx @@ -9,6 +21,16 @@ log = logging.getLogger(__name__) +RESULTS_PER_QUERY = 20 + + +@dataclass +class SearchResult: + url: str + title: str + snippet: str + query: str + def generate_queries(topic: str) -> list[str]: """Use Claude to generate 6 diverse search queries for the topic.""" @@ -24,66 +46,103 @@ def generate_queries(topic: str) -> list[str]: return queries[:6] -def brave_search(query: str, count: int = 20, max_retries: int = 3) -> list[str]: - """Call Brave Search API and return a list of result URLs. - - Retries with exponential backoff on rate limiting (429) and transient - server/network errors; other 4xx errors fail immediately. - """ - headers = { - "Accept": "application/json", - "Accept-Encoding": "gzip", - "X-Subscription-Token": settings.brave_api_key, - } - params = {"q": query, "count": count, "text_decorations": "false"} - +def _get_with_retries( + url: str, *, params: dict, headers: dict, max_retries: int = 3 +) -> httpx.Response: + """GET with exponential backoff on rate limiting (429), 5xx and network errors.""" for attempt in range(max_retries + 1): try: with httpx.Client(timeout=15) as client: - resp = client.get( - "https://api.search.brave.com/res/v1/web/search", - headers=headers, - params=params, - ) + resp = client.get(url, headers=headers, params=params) if resp.status_code == 429 or resp.status_code >= 500: raise httpx.HTTPStatusError( f"retryable status {resp.status_code}", request=resp.request, response=resp ) resp.raise_for_status() - data = resp.json() - results = data.get("web", {}).get("results", []) - return [r["url"] for r in results if "url" in r] - except (httpx.HTTPStatusError, httpx.TransportError): + return resp + except httpx.HTTPStatusError as e: + code = e.response.status_code + if attempt == max_retries or not (code == 429 or code >= 500): + raise + except httpx.TransportError: if attempt == max_retries: raise - time.sleep(2 ** attempt) # 1s, 2s, 4s - return [] + time.sleep(2**attempt) # 1s, 2s, 4s + raise AssertionError("unreachable") -def collect_urls( - topic: str, +def brave_search(query: str, count: int = RESULTS_PER_QUERY) -> list[SearchResult]: + resp = _get_with_retries( + "https://api.search.brave.com/res/v1/web/search", + params={"q": query, "count": count, "text_decorations": "false"}, + headers={"Accept": "application/json", "X-Subscription-Token": settings.brave_api_key}, + ) + return [ + SearchResult(r["url"], r.get("title") or "", r.get("description") or "", query) + for r in resp.json().get("web", {}).get("results", []) + if "url" in r + ][:count] + + +def searxng_search(query: str, count: int = RESULTS_PER_QUERY) -> list[SearchResult]: + if not settings.searxng_url: + raise RuntimeError("SEARCH_PROVIDER=searxng needs SEARXNG_URL (e.g. http://localhost:8888)") + resp = _get_with_retries( + f"{settings.searxng_url.rstrip('/')}/search", + params={"q": query, "format": "json", "language": "en"}, + headers={"Accept": "application/json"}, + ) + return [ + SearchResult(r["url"], r.get("title") or "", r.get("content") or "", query) + for r in resp.json().get("results", []) + if "url" in r + ][:count] + + +def web_search(query: str) -> list[SearchResult]: + if settings.search_provider == "searxng": + return searxng_search(query) + return brave_search(query) + + +def search_all( + queries: list[str], on_query: Callable[[int, int, str, int | None], None] | None = None, -) -> list[str]: +) -> list[SearchResult]: """ - Generate queries, search each, return deduplicated URLs up to the article cap. - - on_query(done, total, query, new_urls) is called after each search; - new_urls is None when that search failed. + Run each query; deduplicate by URL (first query wins) up to the article cap. + on_query(done, total, query, new_results) fires after each search; + new_results is None when that search failed. """ - queries = generate_queries(topic) seen: set[str] = set() - urls: list[str] = [] + results: list[SearchResult] = [] for i, query in enumerate(queries, start=1): new: int | None = 0 try: - for url in brave_search(query): - if url not in seen: - seen.add(url) - urls.append(url) + for r in web_search(query): + if r.url not in seen: + seen.add(r.url) + results.append(r) new += 1 except Exception: log.warning("Search failed for %r", query, exc_info=True) - new = None # failed query — skip, don't crash + new = None # failed query: skip, don't crash if on_query: on_query(i, len(queries), query, new) - return urls[:settings.max_articles] + return results[: settings.max_articles] + + +def collect_results( + topic: str, + on_query: Callable[[int, int, str, int | None], None] | None = None, +) -> tuple[list[str], list[SearchResult]]: + queries = generate_queries(topic) + return queries, search_all(queries, on_query) + + +def collect_urls( + topic: str, + on_query: Callable[[int, int, str, int | None], None] | None = None, +) -> list[str]: + """Generate queries, search each, return deduplicated URLs up to the article cap.""" + return [r.url for r in collect_results(topic, on_query)[1]] diff --git a/backend/pipeline.py b/backend/pipeline.py index a413f89..279819e 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio +import json import logging import threading import time @@ -46,16 +47,51 @@ class Cancelled(BaseException): # noqa: N818 — mirrors asyncio.CancelledError class RunContext: - """Progress reporting, event logging and the cancel flag for one run.""" + """Progress reporting, event logging, the cancel flag and the review pause for one run.""" - def __init__(self, analysis_id: str): + def __init__(self, analysis_id: str, review: bool = False): self.analysis_id = analysis_id + self.review = review self._cancel = threading.Event() self._last_write = 0.0 + # Review pause: the pipeline awaits _resume; approve() or cancel() sets it. + # Both may be called from other threads, hence call_soon_threadsafe. + self._loop: asyncio.AbstractEventLoop | None = None + self._resume: asyncio.Event | None = None + self._plan_ready = False # set (in the search thread) once the plan is saved + self.approved_urls: list[str] | None = None + + def prepare_review(self) -> None: + """Create the pause before searching, so it exists by the time anyone can approve.""" + self._loop = asyncio.get_running_loop() + self._resume = asyncio.Event() + + def plan_ready(self) -> None: + self._plan_ready = True + + async def wait_for_review(self) -> list[str]: + assert self._resume is not None, "prepare_review() first" + if self.cancelled(): + raise Cancelled + await self._resume.wait() + self.check() + return self.approved_urls or [] + + def awaiting_review(self) -> bool: + return self._plan_ready and self._resume is not None and not self._resume.is_set() + + def approve(self, urls: list[str]) -> None: + self.approved_urls = urls + self._wake() + + def _wake(self) -> None: + if self._loop is not None and self._resume is not None: + self._loop.call_soon_threadsafe(self._resume.set) # -- cancellation -- def cancel(self) -> None: self._cancel.set() + self._wake() def cancelled(self) -> bool: return self._cancel.is_set() @@ -91,14 +127,19 @@ def event(self, kind: str, message: str, url: str | None = None) -> None: _runs: dict[str, tuple[asyncio.Task, RunContext]] = {} -def start(analysis_id: str, topic: str) -> None: +def start(analysis_id: str, topic: str, review: bool = False) -> None: """Start a run on the current event loop.""" - ctx = RunContext(analysis_id) + ctx = RunContext(analysis_id, review) task = asyncio.get_running_loop().create_task(run_pipeline(ctx, topic)) _runs[analysis_id] = (task, ctx) task.add_done_callback(lambda _t: _runs.pop(analysis_id, None)) +def context(analysis_id: str) -> RunContext | None: + entry = _runs.get(analysis_id) + return entry[1] if entry else None + + def cancel(analysis_id: str) -> bool: """Ask a running analysis to stop. Returns False if it isn't running here.""" entry = _runs.get(analysis_id) @@ -120,7 +161,15 @@ def cancel_all() -> None: async def run_pipeline(ctx: RunContext, topic: str) -> None: analysis_id = ctx.analysis_id try: - urls = await asyncio.to_thread(_search, ctx, topic) + if ctx.review: + ctx.prepare_review() + await asyncio.to_thread(_search_for_review, ctx, topic) + urls = await ctx.wait_for_review() + if not urls: + raise RuntimeError("No pages were approved for reading.") + ctx.event("stage", f"Approved {len(urls)} pages") + else: + urls = await asyncio.to_thread(_search, ctx, topic) crawled = await asyncio.to_thread(_crawl, ctx, urls) ok_articles = await asyncio.to_thread(_extract_and_store, ctx, crawled, urls) await asyncio.to_thread(_chunk_and_store, ctx, ok_articles) @@ -169,6 +218,43 @@ def on_query(done: int, total: int, query: str, new_urls: int | None) -> None: return urls +def _search_for_review(ctx: RunContext, topic: str) -> None: + """Search, then save the plan and pause until the user approves pages.""" + from backend.ingestion.search import collect_results + + ctx.stage("crawling", SEARCH, "Generating search queries") + + def on_query(done: int, total: int, query: str, new: int | None) -> None: + ctx.event("warning" if new is None else "query", + f"Search failed: {query}" if new is None else f"{query} ({new} new results)") + ctx.progress(SEARCH, done / total, f"Searched {done} of {total} queries") + ctx.check() + + queries, results = collect_results(topic, on_query=on_query) + save_plan(ctx.analysis_id, queries, results) + ctx.plan_ready() # before the status flips, so "reviewing" always means "ready to approve" + update_analysis(ctx.analysis_id, status="reviewing", progress=SEARCH[1], + stage_detail=f"Waiting for you to review {len(results)} pages") + ctx.event("stage", f"Found {len(results)} pages. Waiting for your review") + + +def save_plan(analysis_id: str, queries: list[str], results: list) -> dict: + from dataclasses import asdict + + plan = {"queries": queries, "results": [asdict(r) for r in results]} + update_analysis(analysis_id, plan=json.dumps(plan)) + return plan + + +def load_plan(analysis_id: str) -> dict | None: + conn = connect() + try: + row = conn.execute("SELECT plan FROM analyses WHERE id = ?", (analysis_id,)).fetchone() + finally: + conn.close() + return json.loads(row["plan"]) if row and row["plan"] else None + + def _crawl(ctx: RunContext, urls: list[str]) -> list[dict]: from backend.ingestion.crawler import crawl_urls diff --git a/frontend/openapi.json b/frontend/openapi.json index d71f7d1..a4d9338 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -188,6 +188,145 @@ } } }, + "/api/analyses/{analysis_id}/plan": { + "get": { + "summary": "Get Plan", + "operationId": "get_plan", + "parameters": [ + { + "name": "analysis_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Analysis Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/analyses/{analysis_id}/plan/search": { + "post": { + "summary": "Search Plan", + "description": "Run edited queries again while the analysis waits for review.", + "operationId": "search_plan", + "parameters": [ + { + "name": "analysis_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Analysis Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanSearch" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/analyses/{analysis_id}/plan/approve": { + "post": { + "summary": "Approve Plan", + "description": "Continue the paused analysis with the chosen pages (search results or your own URLs).", + "operationId": "approve_plan", + "parameters": [ + { + "name": "analysis_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Analysis Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanApprove" + } + } + } + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Analysis" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/analyses/{analysis_id}/events": { "get": { "summary": "Get Events", @@ -598,6 +737,7 @@ "enum": [ "queued", "crawling", + "reviewing", "processing", "embedding", "projecting", @@ -679,6 +819,11 @@ "maxLength": 500, "minLength": 1, "title": "Topic" + }, + "review": { + "type": "boolean", + "title": "Review", + "default": false } }, "type": "object", @@ -1121,6 +1266,94 @@ "title": "MapArticle", "description": "An article as placed on the map." }, + "Plan": { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Queries" + }, + "results": { + "items": { + "$ref": "#/components/schemas/PlanResult" + }, + "type": "array", + "title": "Results" + } + }, + "type": "object", + "required": [ + "queries", + "results" + ], + "title": "Plan", + "description": "What a paused analysis found: its queries and the pages they returned." + }, + "PlanApprove": { + "properties": { + "urls": { + "items": { + "type": "string" + }, + "type": "array", + "minItems": 1, + "title": "Urls" + } + }, + "type": "object", + "required": [ + "urls" + ], + "title": "PlanApprove" + }, + "PlanResult": { + "properties": { + "url": { + "type": "string", + "title": "Url" + }, + "title": { + "type": "string", + "title": "Title" + }, + "snippet": { + "type": "string", + "title": "Snippet" + }, + "query": { + "type": "string", + "title": "Query" + } + }, + "type": "object", + "required": [ + "url", + "title", + "snippet", + "query" + ], + "title": "PlanResult" + }, + "PlanSearch": { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 12, + "minItems": 1, + "title": "Queries" + } + }, + "type": "object", + "required": [ + "queries" + ], + "title": "PlanSearch" + }, "Term": { "properties": { "term": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f245008..3960207 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -16,6 +16,8 @@ export type { ChatSource, Cluster, MapArticle, + Plan, + PlanResult, Term } from './api/gen/types.gen'; @@ -67,7 +69,8 @@ const path = (analysis_id: string) => ({ path: { analysis_id } }); export const api = { listAnalyses: (fetch?: Fetch) => unwrap(sdk.listAnalyses({ fetch })), - createAnalysis: (topic: string) => unwrap(sdk.createAnalysis({ body: { topic } })), + createAnalysis: (topic: string, review = false) => + unwrap(sdk.createAnalysis({ body: { topic, review } })), getAnalysis: (id: string, fetch?: Fetch) => unwrap(sdk.getAnalysis({ ...path(id), fetch })), deleteAnalysis: (id: string) => unwrap(sdk.deleteAnalysis(path(id))), cancelAnalysis: (id: string) => unwrap(sdk.cancelAnalysis(path(id))), @@ -75,6 +78,11 @@ export const api = { getClusters: (id: string, fetch?: Fetch) => unwrap(sdk.getClusters({ ...path(id), fetch })), getArticle: (id: string, articleId: string) => unwrap(sdk.getArticle({ path: { analysis_id: id, article_id: articleId } })), + getPlan: (id: string) => unwrap(sdk.getPlan(path(id))), + searchPlan: (id: string, queries: string[]) => + unwrap(sdk.searchPlan({ ...path(id), body: { queries } })), + approvePlan: (id: string, urls: string[]) => + unwrap(sdk.approvePlan({ ...path(id), body: { urls } })), getChat: (id: string) => unwrap(sdk.getChat(path(id))), clearChat: (id: string) => unwrap(sdk.clearChat(path(id))), ask: streamAnswer @@ -133,6 +141,7 @@ async function streamAnswer( export const RUNNING: ReadonlyArray = [ 'queued', 'crawling', + 'reviewing', 'processing', 'embedding', 'projecting' diff --git a/frontend/src/lib/api/gen/index.ts b/frontend/src/lib/api/gen/index.ts index 79aec09..e8ecb44 100644 --- a/frontend/src/lib/api/gen/index.ts +++ b/frontend/src/lib/api/gen/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { ask, cancelAnalysis, clearChat, createAnalysis, deleteAnalysis, exportObsidian, getAnalysis, getArticle, getChat, getClusters, getEvents, health, listAnalyses, listArticles, type Options, streamAnalysis } from './sdk.gen'; -export type { Analysis, AnalysisCreate, AnalysisEvent, Article, AskData, AskError, AskErrors, AskResponses, CancelAnalysisData, CancelAnalysisError, CancelAnalysisErrors, CancelAnalysisResponse, CancelAnalysisResponses, ChatMessage, ChatRequest, ChatSource, ClearChatData, ClearChatError, ClearChatErrors, ClearChatResponse, ClearChatResponses, ClientOptions, Cluster, CreateAnalysisData, CreateAnalysisError, CreateAnalysisErrors, CreateAnalysisResponse, CreateAnalysisResponses, DeleteAnalysisData, DeleteAnalysisError, DeleteAnalysisErrors, DeleteAnalysisResponse, DeleteAnalysisResponses, ExportObsidianData, ExportObsidianError, ExportObsidianErrors, ExportObsidianResponses, GetAnalysisData, GetAnalysisError, GetAnalysisErrors, GetAnalysisResponse, GetAnalysisResponses, GetArticleData, GetArticleError, GetArticleErrors, GetArticleResponse, GetArticleResponses, GetChatData, GetChatError, GetChatErrors, GetChatResponse, GetChatResponses, GetClustersData, GetClustersError, GetClustersErrors, GetClustersResponse, GetClustersResponses, GetEventsData, GetEventsError, GetEventsErrors, GetEventsResponse, GetEventsResponses, HealthData, HealthResponses, HttpValidationError, ListAnalysesData, ListAnalysesResponse, ListAnalysesResponses, ListArticlesData, ListArticlesError, ListArticlesErrors, ListArticlesResponse, ListArticlesResponses, MapArticle, StreamAnalysisData, StreamAnalysisError, StreamAnalysisErrors, StreamAnalysisResponses, Term, ValidationError } from './types.gen'; +export { approvePlan, ask, cancelAnalysis, clearChat, createAnalysis, deleteAnalysis, exportObsidian, getAnalysis, getArticle, getChat, getClusters, getEvents, getPlan, health, listAnalyses, listArticles, type Options, searchPlan, streamAnalysis } from './sdk.gen'; +export type { Analysis, AnalysisCreate, AnalysisEvent, ApprovePlanData, ApprovePlanError, ApprovePlanErrors, ApprovePlanResponse, ApprovePlanResponses, Article, AskData, AskError, AskErrors, AskResponses, CancelAnalysisData, CancelAnalysisError, CancelAnalysisErrors, CancelAnalysisResponse, CancelAnalysisResponses, ChatMessage, ChatRequest, ChatSource, ClearChatData, ClearChatError, ClearChatErrors, ClearChatResponse, ClearChatResponses, ClientOptions, Cluster, CreateAnalysisData, CreateAnalysisError, CreateAnalysisErrors, CreateAnalysisResponse, CreateAnalysisResponses, DeleteAnalysisData, DeleteAnalysisError, DeleteAnalysisErrors, DeleteAnalysisResponse, DeleteAnalysisResponses, ExportObsidianData, ExportObsidianError, ExportObsidianErrors, ExportObsidianResponses, GetAnalysisData, GetAnalysisError, GetAnalysisErrors, GetAnalysisResponse, GetAnalysisResponses, GetArticleData, GetArticleError, GetArticleErrors, GetArticleResponse, GetArticleResponses, GetChatData, GetChatError, GetChatErrors, GetChatResponse, GetChatResponses, GetClustersData, GetClustersError, GetClustersErrors, GetClustersResponse, GetClustersResponses, GetEventsData, GetEventsError, GetEventsErrors, GetEventsResponse, GetEventsResponses, GetPlanData, GetPlanError, GetPlanErrors, GetPlanResponse, GetPlanResponses, HealthData, HealthResponses, HttpValidationError, ListAnalysesData, ListAnalysesResponse, ListAnalysesResponses, ListArticlesData, ListArticlesError, ListArticlesErrors, ListArticlesResponse, ListArticlesResponses, MapArticle, Plan, PlanApprove, PlanResult, PlanSearch, SearchPlanData, SearchPlanError, SearchPlanErrors, SearchPlanResponse, SearchPlanResponses, StreamAnalysisData, StreamAnalysisError, StreamAnalysisErrors, StreamAnalysisResponses, Term, ValidationError } from './types.gen'; diff --git a/frontend/src/lib/api/gen/sdk.gen.ts b/frontend/src/lib/api/gen/sdk.gen.ts index e197d0f..b7a5d4a 100644 --- a/frontend/src/lib/api/gen/sdk.gen.ts +++ b/frontend/src/lib/api/gen/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, ServerSentEventsResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { AskData, AskErrors, AskResponses, CancelAnalysisData, CancelAnalysisErrors, CancelAnalysisResponses, ClearChatData, ClearChatErrors, ClearChatResponses, CreateAnalysisData, CreateAnalysisErrors, CreateAnalysisResponses, DeleteAnalysisData, DeleteAnalysisErrors, DeleteAnalysisResponses, ExportObsidianData, ExportObsidianErrors, ExportObsidianResponses, GetAnalysisData, GetAnalysisErrors, GetAnalysisResponses, GetArticleData, GetArticleErrors, GetArticleResponses, GetChatData, GetChatErrors, GetChatResponses, GetClustersData, GetClustersErrors, GetClustersResponses, GetEventsData, GetEventsErrors, GetEventsResponses, HealthData, HealthResponses, ListAnalysesData, ListAnalysesResponses, ListArticlesData, ListArticlesErrors, ListArticlesResponses, StreamAnalysisData, StreamAnalysisErrors, StreamAnalysisResponses } from './types.gen'; +import type { ApprovePlanData, ApprovePlanErrors, ApprovePlanResponses, AskData, AskErrors, AskResponses, CancelAnalysisData, CancelAnalysisErrors, CancelAnalysisResponses, ClearChatData, ClearChatErrors, ClearChatResponses, CreateAnalysisData, CreateAnalysisErrors, CreateAnalysisResponses, DeleteAnalysisData, DeleteAnalysisErrors, DeleteAnalysisResponses, ExportObsidianData, ExportObsidianErrors, ExportObsidianResponses, GetAnalysisData, GetAnalysisErrors, GetAnalysisResponses, GetArticleData, GetArticleErrors, GetArticleResponses, GetChatData, GetChatErrors, GetChatResponses, GetClustersData, GetClustersErrors, GetClustersResponses, GetEventsData, GetEventsErrors, GetEventsResponses, GetPlanData, GetPlanErrors, GetPlanResponses, HealthData, HealthResponses, ListAnalysesData, ListAnalysesResponses, ListArticlesData, ListArticlesErrors, ListArticlesResponses, SearchPlanData, SearchPlanErrors, SearchPlanResponses, StreamAnalysisData, StreamAnalysisErrors, StreamAnalysisResponses } from './types.gen'; export type Options = Options2 & { /** @@ -57,6 +57,39 @@ export const getAnalysis = (options: Optio */ export const cancelAnalysis = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/analyses/{analysis_id}/cancel', ...options }); +/** + * Get Plan + */ +export const getPlan = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/analyses/{analysis_id}/plan', ...options }); + +/** + * Search Plan + * + * Run edited queries again while the analysis waits for review. + */ +export const searchPlan = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/analyses/{analysis_id}/plan/search', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Approve Plan + * + * Continue the paused analysis with the chosen pages (search results or your own URLs). + */ +export const approvePlan = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/analyses/{analysis_id}/plan/approve', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Get Events */ diff --git a/frontend/src/lib/api/gen/types.gen.ts b/frontend/src/lib/api/gen/types.gen.ts index 540b557..3f271c9 100644 --- a/frontend/src/lib/api/gen/types.gen.ts +++ b/frontend/src/lib/api/gen/types.gen.ts @@ -19,7 +19,7 @@ export type Analysis = { /** * Status */ - status: 'queued' | 'crawling' | 'processing' | 'embedding' | 'projecting' | 'done' | 'error' | 'cancelled'; + status: 'queued' | 'crawling' | 'reviewing' | 'processing' | 'embedding' | 'projecting' | 'done' | 'error' | 'cancelled'; /** * Progress */ @@ -58,6 +58,10 @@ export type AnalysisCreate = { * Topic */ topic: string; + /** + * Review + */ + review?: boolean; }; /** @@ -296,6 +300,64 @@ export type MapArticle = { distance: number; }; +/** + * Plan + * + * What a paused analysis found: its queries and the pages they returned. + */ +export type Plan = { + /** + * Queries + */ + queries: Array; + /** + * Results + */ + results: Array; +}; + +/** + * PlanApprove + */ +export type PlanApprove = { + /** + * Urls + */ + urls: Array; +}; + +/** + * PlanResult + */ +export type PlanResult = { + /** + * Url + */ + url: string; + /** + * Title + */ + title: string; + /** + * Snippet + */ + snippet: string; + /** + * Query + */ + query: string; +}; + +/** + * PlanSearch + */ +export type PlanSearch = { + /** + * Queries + */ + queries: Array; +}; + /** * Term */ @@ -485,6 +547,96 @@ export type CancelAnalysisResponses = { export type CancelAnalysisResponse = CancelAnalysisResponses[keyof CancelAnalysisResponses]; +export type GetPlanData = { + body?: never; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/plan'; +}; + +export type GetPlanErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPlanError = GetPlanErrors[keyof GetPlanErrors]; + +export type GetPlanResponses = { + /** + * Successful Response + */ + 200: Plan; +}; + +export type GetPlanResponse = GetPlanResponses[keyof GetPlanResponses]; + +export type SearchPlanData = { + body: PlanSearch; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/plan/search'; +}; + +export type SearchPlanErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type SearchPlanError = SearchPlanErrors[keyof SearchPlanErrors]; + +export type SearchPlanResponses = { + /** + * Successful Response + */ + 200: Plan; +}; + +export type SearchPlanResponse = SearchPlanResponses[keyof SearchPlanResponses]; + +export type ApprovePlanData = { + body: PlanApprove; + path: { + /** + * Analysis Id + */ + analysis_id: string; + }; + query?: never; + url: '/api/analyses/{analysis_id}/plan/approve'; +}; + +export type ApprovePlanErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ApprovePlanError = ApprovePlanErrors[keyof ApprovePlanErrors]; + +export type ApprovePlanResponses = { + /** + * Successful Response + */ + 202: Analysis; +}; + +export type ApprovePlanResponse = ApprovePlanResponses[keyof ApprovePlanResponses]; + export type GetEventsData = { body?: never; path: { diff --git a/frontend/src/lib/api/stream.ts b/frontend/src/lib/api/stream.ts index acfee6c..091be8d 100644 --- a/frontend/src/lib/api/stream.ts +++ b/frontend/src/lib/api/stream.ts @@ -22,7 +22,9 @@ export function followAnalysis(id: string, handlers: StreamHandlers): () => void const analysis = JSON.parse(e.data) as Analysis; handlers.onAnalysis(analysis); if ( - !['queued', 'crawling', 'processing', 'embedding', 'projecting'].includes(analysis.status) + !['queued', 'crawling', 'reviewing', 'processing', 'embedding', 'projecting'].includes( + analysis.status + ) ) { finished = true; source.close(); diff --git a/frontend/src/lib/components/ProgressView.svelte b/frontend/src/lib/components/ProgressView.svelte index 86ab224..28d65f3 100644 --- a/frontend/src/lib/components/ProgressView.svelte +++ b/frontend/src/lib/components/ProgressView.svelte @@ -10,6 +10,7 @@ import Shapes from '@lucide/svelte/icons/shapes'; import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; import { toast } from 'svelte-sonner'; + import ReviewPanel from './ReviewPanel.svelte'; let { analysis: initial }: { analysis: Analysis } = $props(); @@ -72,82 +73,86 @@ } -
    -
    -

    - {analysis.stage_detail ?? 'Starting'} -

    -
    +{#if analysis.status === 'reviewing'} + +{:else} +
    +
    +

    + {analysis.stage_detail ?? 'Starting'} +

    -
    -
    -
    -
    Done
    -
    {analysis.progress}%
    -
    -
    -
    Articles kept
    -
    {indexed}
    -
    -
    -
    Elapsed
    -
    {formatDuration(elapsed)}
    + class="mt-4 h-2 overflow-hidden rounded-full bg-rule-soft" + role="progressbar" + aria-label="Mapping progress" + aria-valuemin={0} + aria-valuemax={100} + aria-valuenow={analysis.progress} + > +
    -
    -

    - Most maps take 3 to 8 minutes. You can leave this page; the map keeps building and shows up in - your list. -

    - -
    +
    +
    +
    Done
    +
    {analysis.progress}%
    +
    +
    +
    Articles kept
    +
    {indexed}
    +
    +
    +
    Elapsed
    +
    {formatDuration(elapsed)}
    +
    +
    +

    + Most maps take 3 to 8 minutes. You can leave this page; the map keeps building and shows up + in your list. +

    + +
    -
    -

    - Activity - - {plural(indexed, 'article')} kept, {plural(skipped, 'page')} skipped - -

    -
      - {#each newestFirst as e (e.id)} - {@const Icon = ICONS[e.kind]} -
    1. +

      + Activity + + {plural(indexed, 'article')} kept, {plural(skipped, 'page')} skipped + +

      +
        + {#each newestFirst as e (e.id)} + {@const Icon = ICONS[e.kind]} +
      1. -
      2. - {:else} -
      3. Waiting for the first step…
      4. - {/each} -
      -
    -
    + > +
    + +{/if} diff --git a/frontend/src/lib/components/ReviewPanel.svelte b/frontend/src/lib/components/ReviewPanel.svelte new file mode 100644 index 0000000..efb4dab --- /dev/null +++ b/frontend/src/lib/components/ReviewPanel.svelte @@ -0,0 +1,242 @@ + + +
    + {#if loadError} + + {:else if !plan} +

    Loading the search results…

    + {:else} +
    +
    +

    Check the sources before reading

    +

    + Untick pages you don't want, add your own, or edit the searches and run them again. +

    + +

    Searches

    +
      + {#each queries as query, i (i)} +
    • + + + +
    • + {/each} +
    +
    + + +
    + +

    + +

    + + {#if invalidExtra.length} +

    Not a web address: {invalidExtra[0]}

    + {/if} + +
    + + +
    +
    + +
    + {#each groups as [query, results] (query)} + {@const allOn = results.every((r) => chosen.has(r.url))} +
    +
    +

    {query}

    + +
    +
      + {#each results as r (r.url)} +
    • + + e.currentTarget.checked ? chosen.add(r.url) : chosen.delete(r.url)} + /> +
      + +

      + + + {host(r.url)} + + +

      + {#if r.snippet}

      + {r.snippet} +

      {/if} +
      +
    • + {/each} +
    +
    + {:else} +

    The searches found nothing. Edit them and search again.

    + {/each} +
    +
    + {/if} +
    diff --git a/frontend/src/lib/components/StatusLabel.svelte b/frontend/src/lib/components/StatusLabel.svelte index 2a0b45a..a0d29ba 100644 --- a/frontend/src/lib/components/StatusLabel.svelte +++ b/frontend/src/lib/components/StatusLabel.svelte @@ -3,6 +3,7 @@ import { isRunning } from '#lib/api.ts'; import { plural } from '#lib/format.ts'; import CircleCheck from '@lucide/svelte/icons/circle-check'; + import CircleDot from '@lucide/svelte/icons/circle-dot'; import CircleX from '@lucide/svelte/icons/circle-x'; import CircleSlash from '@lucide/svelte/icons/circle-slash'; import LoaderCircle from '@lucide/svelte/icons/loader-circle'; @@ -20,6 +21,9 @@ {:else if analysis.status === 'cancelled'}