diff --git a/.env.example b/.env.example index 11ada68e3..ad02bee04 100644 --- a/.env.example +++ b/.env.example @@ -30,13 +30,6 @@ EVEROS_LLM__MODEL=openai/gpt-4.1-mini EVEROS_LLM__API_KEY= EVEROS_LLM__BASE_URL=https://openrouter.ai/api/v1 -# Aliyun Bailian (DashScope) alternative — one key also covers embedding + -# rerank below, so the same value goes in all three API_KEY slots. -# Uncomment these three lines and comment out the OpenRouter lines above: -# EVEROS_LLM__MODEL=qwen-plus -# EVEROS_LLM__API_KEY= -# EVEROS_LLM__BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 - # ─── Multimodal LLM (independent from [llm]; vision/audio capable) ──── # Separate model for parsing multimodal content items (image / pdf / @@ -66,12 +59,6 @@ EVEROS_MULTIMODAL__BASE_URL=https://openrouter.ai/api/v1 EVEROS_EMBEDDING__MODEL=Qwen/Qwen3-Embedding-4B EVEROS_EMBEDDING__API_KEY= EVEROS_EMBEDDING__BASE_URL=https://api.deepinfra.com/v1/openai - -# Aliyun Bailian (DashScope) alternative. Uncomment these three lines and -# comment out the DeepInfra embedding lines above: -# EVEROS_EMBEDDING__MODEL=text-embedding-v4 -# EVEROS_EMBEDDING__API_KEY= -# EVEROS_EMBEDDING__BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # Runtime knobs — uncomment to override defaults (30s / 3 / 10 / 5): # EVEROS_EMBEDDING__TIMEOUT_SECONDS=30 # EVEROS_EMBEDDING__MAX_RETRIES=3 @@ -81,21 +68,10 @@ EVEROS_EMBEDDING__BASE_URL=https://api.deepinfra.com/v1/openai # ─── Rerank (OpenAI-protocol /rerank) ──────────────── # base_url should point at the rerank endpoint (e.g. .../v1/rerank). -# EVEROS_RERANK__PROVIDER selects the request-shape: "deepinfra" (default), -# "vllm", or "dashscope". Left unset, it is inferred from the base_url host -# (dashscope.aliyuncs.com -> dashscope, *.deepinfra.com -> deepinfra). EVEROS_RERANK__MODEL=Qwen/Qwen3-Reranker-4B EVEROS_RERANK__API_KEY= EVEROS_RERANK__BASE_URL=https://api.deepinfra.com/v1/inference - -# Aliyun Bailian (DashScope) alternative. EverOS currently supports -# DashScope rerank via gte-rerank-v2; provider is auto-inferred from host, -# so EVEROS_RERANK__PROVIDER is optional. Uncomment these three lines and -# comment out the DeepInfra rerank lines above: -# EVEROS_RERANK__MODEL=gte-rerank-v2 -# EVEROS_RERANK__API_KEY= -# EVEROS_RERANK__BASE_URL=https://dashscope.aliyuncs.com # Runtime knobs — uncomment to override defaults (30s / 3 / 10 / 5): # EVEROS_RERANK__TIMEOUT_SECONDS=30 # EVEROS_RERANK__MAX_RETRIES=3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80943a446..35e15e195 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,6 @@ +# CI is a thin wrapper over the Makefile — single source of truth for commands. +# See docs/engineering.md and the Makefile for what each target does. + name: CI on: @@ -32,7 +35,7 @@ jobs: - name: Install dependencies (frozen) run: make install-deps - - name: Lint (ruff + import-linter + datetime + openapi drift) + - name: Lint (ruff + import-linter + repo gates + datetime + openapi drift) run: make lint unit: @@ -56,6 +59,27 @@ jobs: - name: Unit tests run: make test + unit-py313: + name: unit tests (3.13) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies (frozen) + run: make install-deps + + - name: Unit tests + run: make test + integration: name: integration tests runs-on: ubuntu-latest @@ -77,6 +101,27 @@ jobs: - name: Integration tests run: make integration + integration-py313: + name: integration tests (3.13) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies (frozen) + run: make install-deps + + - name: Integration tests + run: make integration + package: name: package build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6bd1a98c7..fa370a0f3 100755 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ celerybeat.pid # Environments .env .env.* +!.env.example .venv env/ venv/ @@ -258,13 +259,17 @@ evaluation/locomo_evaluation/data/locomo10.json # Legacy src kept locally for migration reference; not under version control. src_old/ -# Benchmark checkpoints — Phase-level intermediate JSON dumps produced by -# tests/test_locomo.py / tests/run_locomo_batch.sh. Per-conv final results -# (benchmark_results/run_*/convN.json) and reports (REPORT.md / REPORT.html) -# stay tracked; checkpoints are large, regeneratable, and not useful in -# code review. +# Benchmark results — JSONL outputs, reports, and run specs produced by +# benchmarks/run.py. These are large, regeneratable, and not useful in +# code review. Legacy benchmark_results/ and benchmark_checkpoints/ kept +# for any lingering local data from pre-refactor runs. +benchmarks/results/ benchmark_results/ benchmark_checkpoints/ +# Benchmark credentials — API keys + base URLs; covered by the top-level +# .env pattern but listed explicitly for discoverability. +benchmarks/.env + # Local everos runtime data (memory root, indexes, OME state) -.everos/ +.everos*/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 036d85a9c..e9fbb8299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.1] - 2026-07-06 + +### Added + +- **DashScope rerank provider** — Aliyun Bailian `gte-rerank-v2` adapter; + configure with `rerank.provider = "dashscope"` in `everos.toml`. +- **`everos demo` TUI command** — Textual-based interactive CLI demo for + showcasing EverOS core features. +- **Benchmark runner** — full LoCoMo benchmark suite: `benchmarks/run.py` + with TOML configuration, automated ingestion, search evaluation, and + scoring. +- **Hybrid search: heap-expand algorithm** — rewrote `hierarchy.py` to + heap-driven lazy expansion with global top-N competition, replacing the + serial four-layer pipeline. + +### Fixed + +- **Knowledge: atomic upsert prevents StaleDataError** — cascade handler + switched from get→update to `INSERT ... ON CONFLICT DO UPDATE`, fixing + concurrent cascade race conditions. +- **API: OpenAPI version read from `__version__`** — no longer hardcoded to + `0.1.0`; version now stays in sync with `pyproject.toml`. +- **Profile middleware no longer swallows exceptions** — inner handler + errors now re-raise correctly instead of silently returning HTTP 200. + +### Performance + +- **Cascade optimize throttle 1s → 10s** — reduced unnecessary LanceDB + `optimize()` I/O by raising the minimum interval between calls. + +### CI / Build + +- **CI Python version matrix** — test and integration jobs now run on both + Python 3.12 and 3.13. +- **pyproject.toml improvements** — added `project.urls`, `Typing :: Typed` + classifier, relaxed `jieba` version constraint, removed unused + `python-dotenv` dependency, cleaned up sdist include list, added `RUF` + lint rules and coverage configuration. +- **`make ci` includes coverage** — `ci` target now runs + `lint + test + integration + cov`. + +### Documentation + +- Fixed stale references across 13 files (v1.1.0 freshness sweep). +- Added GitHub sync guide (`docs/github-sync.md`). +- Added v1.1.0 release notes and v1.0.0 migration guide as standalone docs. +- Added `README.zh-CN.md` (Chinese README). +- Expanded `QUICKSTART.md` with source install instructions and `uv run` + usage notes. +- Clarified cascade `optimize()` semantics in docstrings and runbook. + ## [1.1.0] - 2026-06-24 ### Added @@ -164,7 +215,8 @@ for AI agents. - **Decoupled algorithms** — memory extraction algorithms live in the standalone `everalgo-*` libraries published on PyPI. -[Unreleased]: https://github.com/EverMind-AI/everos/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/EverMind-AI/everos/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/EverMind-AI/everos/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/EverMind-AI/everos/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/EverMind-AI/everos/releases/tag/v1.0.1 [1.0.0]: https://github.com/EverMind-AI/everos/releases/tag/v1.0.0 diff --git a/QUICKSTART.md b/QUICKSTART.md index 250dfde43..5d5f8ab78 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -10,107 +10,147 @@ front of your agent. ## Prerequisites - **Python 3.12+** -- **An LLM provider key and endpoint** — for memory extraction. OpenRouter, - OpenAI, and other OpenAI-compatible providers work when you set - `base_url`. -- **A multimodal provider key and endpoint** — needed only when parsing - image / pdf / audio content items. -- **Embedding and rerank provider keys and endpoints** — for search. DeepInfra - works for the embedding + rerank path; vLLM and DashScope are also supported - for rerank. - -Many deployments use two distinct keys by reusing one LLM key for `[llm]` and -`[multimodal]`, and one DeepInfra key for `[embedding]` and `[rerank]`. Any -setting can live in TOML or be overridden by the matching `EVEROS_*` -environment variable. +- **API keys** for three capabilities: a chat LLM (memory extraction), + an embedding model (vector retrieval), and a reranker. Any + OpenAI-compatible endpoint works. ## 1. Install +**From PyPI** (users): + ```bash pip install everos # or: uv pip install everos ``` -## 2. Configure +**From source** (contributors / developers): + +```bash +git clone https://github.com/EverMind-AI/EverOS.git +cd EverOS +uv sync # install all deps into .venv +``` -Generate the starter config and fill in provider credentials: +> **Note:** source install creates a `.venv` virtualenv. Subsequent +> `everos` commands need either `uv run everos ...` or activate the venv +> first (`source .venv/bin/activate`). + +## 2. Configure ```bash -everos init # writes ~/.everos/everos.toml + ome.toml (use --root to relocate) -# Edit ~/.everos/everos.toml and fill the provider fields: -# [llm] api_key + base_url (chat LLM) -# [multimodal] api_key + base_url (optional parser LLM) -# [embedding] model + api_key + base_url -# [rerank] model + api_key + base_url (provider can be inferred or set) +everos init # default root: ~/.everos +everos init --root /data/everos # or specify a custom root ``` -`everos init` generates two files: `everos.toml` (provider settings) -and `ome.toml` (offline memory engine strategy config, hot-reloaded). -Because `everos.toml` holds API keys, consider restricting access -after editing: `chmod 600 ~/.everos/everos.toml`. - -The shipped template sets model defaults for `[llm]` (`gpt-4.1-mini`) and -`[multimodal]` (`google/gemini-3-flash-preview`); `[embedding]` and -`[rerank]` ship no model default — set `model` + `base_url` for those two -sections yourself (for example DeepInfra's `Qwen/Qwen3-Embedding-4B` / -`Qwen/Qwen3-Reranker-4B`). `[llm]` and `[multimodal]` ship model defaults, -but still need `api_key` and `base_url` before those capabilities are used. - -> **Where config lives** — `everos init` writes into the memory root -> (`~/.everos` by default; relocate with `everos init --root ` and -> start the server with the matching `everos server start --root `). -> `everos server start` reads `/everos.toml` and exits with an error -> if it is missing. Any setting can also be overridden by an `EVEROS_*` -> environment variable (e.g. `EVEROS_LLM__API_KEY`) — handy for containers +> **Root directory** — defaults to `~/.everos`. Use `--root ` to +> relocate; all subsequent commands (`server start`, `cascade status`, +> etc.) must use the matching `--root`. Any setting in `everos.toml` can +> also be overridden via `EVEROS_*` environment variables for containers > and CI. +This creates `everos.toml` and `ome.toml` under the root directory. +Open `everos.toml` and fill in three sections — here's the minimum +viable config: + +```toml +[llm] +model = "gpt-4.1-mini" # or your preferred model +base_url = "https://openrouter.ai/api/v1" # any OpenAI-compatible endpoint +api_key = "sk-..." # your API key + +[embedding] +model = "Qwen/Qwen3-Embedding-4B" +base_url = "https://api.deepinfra.com/v1/openai" +api_key = "..." + +[rerank] +provider = "deepinfra" +model = "Qwen/Qwen3-Reranker-4B" +base_url = "https://api.deepinfra.com/v1/inference" +api_key = "..." +``` + +The generated file pre-fills recommended `model` and `base_url` +defaults — just drop in your API keys. Any OpenAI-compatible endpoint +works. + +> **Multimodal** (`[multimodal]`) is optional — only needed when +> ingesting image / pdf / audio content items. See +> [docs/multimodal.md](docs/multimodal.md) for setup. + + ## 3. Start the server +Check your file descriptor limit — EverOS opens many LanceDB segment +files under concurrent search + indexing. Platform defaults: +**macOS 256** · **Linux 1024** · **Windows 8192**. If yours is below +4096, raise it before starting: + +Run these in the **same terminal** where you will start the server — +`ulimit` is per-shell-session, not global: + ```bash -everos server start +ulimit -n # check current limit +ulimit -n 4096 # raise if needed +everos server start [--root ] # must be in the same session ``` -You should see (port and host are configurable): +> **No side effects** — `ulimit -n` only raises the per-process ceiling. +> It does not pre-allocate memory or file handles, and has zero +> performance cost. For Linux production, set `LimitNOFILE=65536` in +> your systemd unit file. + +You should see: ``` starting everos on 127.0.0.1:8000 INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) ``` -- Default bind is `127.0.0.1` (loopback only). To expose the API - elsewhere, put your own auth/gateway in front first - ([see SECURITY.md](SECURITY.md)). -- The cascade index daemon runs **in the same process** as a FastAPI - lifespan coroutine — you don't need a separate worker. -- The server runs in the foreground; **open a second terminal** for the - steps below, and use `Ctrl+C` to stop the server when you're done. +The server runs in the foreground. **Open a second terminal** for the +steps below. -In the second terminal, verify the server is up: +Verify it's up: ```bash -$ curl http://127.0.0.1:8000/health -{"status":"ok"} +curl http://127.0.0.1:8000/health +# {"status":"ok"} ``` ## 4. Add a conversation -EverOS ingests memory at the **conversation level**, not as standalone -sentences: you POST a batch of `messages` tied to a `session_id`, and -the server accumulates them until the boundary detector trips (you can -also force a flush — see step 5). +Send messages to the server — one at a time or in batches. Each batch +belongs to a `session_id`, which represents one conversation thread. +Timestamps are Unix epoch in **milliseconds** (UTC). + +First, a chat about climbing: ```bash -TS=$(($(date +%s)*1000)) # Unix epoch in **milliseconds** (v1 contract) +TS=$(($(date +%s)*1000)) curl -X POST http://127.0.0.1:8000/api/v1/memory/add \ -H 'Content-Type: application/json' \ -d "{ \"session_id\": \"demo-001\", - \"app_id\": \"default\", - \"project_id\": \"default\", \"messages\": [ - {\"sender_id\": \"alice\", \"role\": \"user\", \"timestamp\": $TS, \"content\": \"I love climbing in Yosemite every spring.\"}, - {\"sender_id\": \"alice\", \"role\": \"user\", \"timestamp\": $((TS+10000)), \"content\": \"My favorite coffee shop is Blue Bottle in SOMA.\"}, - {\"sender_id\": \"alice\", \"role\": \"user\", \"timestamp\": $((TS+20000)), \"content\": \"I bike to work most days.\"} + {\"sender_id\": \"alice\", \"role\": \"user\", \"timestamp\": $TS, \"content\": \"I just got back from a week in Yosemite. The climbing was incredible.\"}, + {\"sender_id\": \"agent1\", \"role\": \"assistant\", \"timestamp\": $((TS+10000)), \"content\": \"That sounds amazing! Which routes did you do?\"}, + {\"sender_id\": \"alice\", \"role\": \"user\", \"timestamp\": $((TS+20000)), \"content\": \"Mostly cracks on El Cap. I go every spring — it's my favorite season there.\"} + ] + }" +# → status: "accumulated" +``` + +Now the topic shifts to work: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/memory/add \ + -H 'Content-Type: application/json' \ + -d "{ + \"session_id\": \"demo-001\", + \"messages\": [ + {\"sender_id\": \"alice\", \"role\": \"user\", \"timestamp\": $((TS+60000)), \"content\": \"By the way, I switched to biking to work last month. Loving it so far.\"}, + {\"sender_id\": \"agent1\", \"role\": \"assistant\", \"timestamp\": $((TS+70000)), \"content\": \"How long is your commute?\"}, + {\"sender_id\": \"alice\", \"role\": \"user\", \"timestamp\": $((TS+80000)), \"content\": \"About 25 minutes. I stop at Blue Bottle in SOMA for coffee most mornings.\"} ] }" ``` @@ -119,52 +159,53 @@ Response: ```json { - "request_id": "bf86e4e857834eba804841f8bff29106", "data": { "message_count": 3, - "status": "accumulated" + "status": "extracted" } } ``` -`status: "accumulated"` means the three messages are in the session -buffer, but the boundary detector hasn't decided to extract a memory -cell yet. For a quick demo we'll force it. +EverOS detected a topic shift (climbing → commute) and automatically +extracted the earlier conversation into memory. + +The `status` field tells you what happened: + +| Status | Meaning | +|---|---| +| `accumulated` | Messages buffered, still part of the same topic. | +| `extracted` | Topic shift detected — memory extracted from the buffer. | + +> For the full API contract, see [docs/openapi.json](docs/openapi.json). -## 5. Force boundary extraction +## 5. Flush (manual extraction) + +If you want to extract memory without waiting for a topic shift — for +example at the end of a session — call `/flush`: ```bash curl -X POST http://127.0.0.1:8000/api/v1/memory/flush \ -H 'Content-Type: application/json' \ - -d '{"session_id":"demo-001","app_id":"default","project_id":"default"}' + -d '{"session_id":"demo-001"}' ``` -Response (this takes a few seconds — one LLM call for extraction): - ```json { - "request_id": "ec0e7a00c3bd4b00bb21212a411b7763", "data": { "status": "extracted" } } ``` -`status: "extracted"` means at least one memory cell was carved out and -written to disk + indexed. - -> `/flush` is **OSS-only**. The cloud edition decides boundary timing -> server-side and does not expose this endpoint. +This forces extraction of whatever is still in the buffer. -## 6. Search the memory you just added +## 6. Search ```bash curl -X POST http://127.0.0.1:8000/api/v1/memory/search \ -H 'Content-Type: application/json' \ -d '{ "user_id": "alice", - "app_id": "default", - "project_id": "default", "query": "Where do I like to climb?", "top_k": 5 }' @@ -174,158 +215,78 @@ Response (trimmed): ```json { - "request_id": "b53a3a94a080472d97692c503c88afdf", "data": { "episodes": [ { "id": "alice_ep_20260528_00000002", - "user_id": "alice", - "session_id": "demo-001", - "summary": "On May 28, 2026 ... Alice shared that she loves climbing in Yosemite every spring ...", - "score": 0.6284722685813904, + "summary": "... Alice shared that she loves climbing in Yosemite every spring ...", + "score": 0.628, "atomic_facts": [ { - "id": "alice_af_20260528_00000016", "content": "Alice said she loves climbing in Yosemite every spring.", - "score": 0.6284722685813904 + "score": 0.628 } ] } - ], - "profiles": [], - "agent_cases": [], - "agent_skills": [] + ] } } ``` -The hybrid retrieval (BM25 + vector + scalar) returns the episode -that contains the climbing fact, with the matching atomic fact nested -under it. Other response arrays (`profiles` / `agent_cases` / -`agent_skills`) are always present for client-side symmetry, populated -only when the requested kind matches. +Hybrid retrieval (BM25 + vector + scalar) returns the matching episode +with its atomic facts nested under it. ## 7. Your memory is just Markdown -This is what makes EverOS different — your memory persists as plain -Markdown files on disk: +This is what makes EverOS different — memory persists as plain Markdown: ``` -$ tree ~/.everos -L 5 -a -~/.everos -├── default_app/ ← app_id ("default" → "default_app") -│ └── default_project/ ← project_id ("default" → "default_project") -│ ├── users/ -│ │ └── alice/ ← user_id (mirror dir: agents//) -│ │ ├── episodes/ -│ │ │ └── episode-2026-05-28.md -│ │ ├── .atomic_facts/ ← hidden (dot-prefix) -│ │ │ └── atomic_fact-2026-05-28.md -│ │ ├── .foresights/ -│ │ │ └── foresight-2026-05-28.md -│ │ └── user.md ← profile -│ └── knowledge/ ← shared knowledge base (v1.1+) -│ └── .taxonomy.md -├── everos.toml ← provider config (API keys) +/ ← ~/.everos or your --root path +├── default_app/ ← app_id ("default" → "default_app") +│ └── default_project/ ← project_id ("default" → "default_project") +│ ├── users// +│ │ ├── user.md ← profile +│ │ ├── episodes/ ← daily-log episodes +│ │ ├── .atomic_facts/ ← nested facts (dot-hidden) +│ │ └── .foresights/ ← predictive memory (dot-hidden) +│ ├── agents// +│ │ ├── agent.md +│ │ ├── .cases/ ← task cases +│ │ └── skills/ ← procedural memories +│ └── knowledge/ ← shared knowledge base +├── everos.toml ← provider config ├── ome.toml ← strategy config (hot-reloaded) ├── .index/ ← derived indexes (rebuildable from md) │ ├── sqlite/system.db -│ └── lancedb/*.lance/ +│ └── lancedb/ └── .tmp/ ``` -The `default` scope id materialises as `default_app` / `default_project` -on disk (with the `_app` / `_project` suffix) so the default space is -visually distinct from any user-named space. Any other id maps to itself -(e.g. `app_id: "my-app"` → `my-app/`). - -Top-level `.index/` holds SQLite + LanceDB **derived** indexes — wipe it -and the cascade daemon rebuilds everything from the Markdown alone. - -Read the episode we just created: - -``` -$ cat ~/.everos/default_app/default_project/users/alice/episodes/episode-2026-05-28.md ---- -id: episode_log_alice_2026-05-28 -type: episode_daily -file_type: episode_daily -schema_version: 1 -user_id: alice -track: user -date: '2026-05-28' -entry_count: 1 -last_appended_at: '2026-05-28T08:32:24.966944+00:00' ---- - -## ep_20260528_00000002 - -**owner_id**: alice -**session_id**: demo-001 -**timestamp**: 2026-05-28T08:32:13+00:00 -**parent_type**: memcell -**parent_id**: mc_3779c20f1c53 -**sender_ids**: [alice] - -### Subject -Alice's Outdoor Activities and Daily Routine on May 28, 2026 Morning - -### Content -On May 28, 2026 at 8:32 AM UTC, Alice shared that she loves climbing in -Yosemite every spring, highlighting a recurring seasonal outdoor activity. -She also mentioned that her favorite coffee shop is Blue Bottle located in -SOMA, indicating a preferred local spot. Additionally, Alice stated that -she bikes to work most days, revealing a habitual commuting practice. - -``` - -Every memory entry is a plain Markdown file you can: - -- `cat` / `grep` / `vim` directly — no driver, no service to query -- Version with Git (or rsync to backup) -- Open the `~/.everos/default_app/default_project/users/alice/` folder - in Obsidian (the dotfile directories stay hidden by default) +Every memory entry is a plain Markdown file you can directly read and +edit — no database driver needed. ## Stopping the server -`Ctrl+C` in the server terminal. Uvicorn catches `SIGINT` and shuts each -lifespan provider down in reverse order (cascade → LanceDB → SQLite → -LLM → metrics) before exiting. +`Ctrl+C` in the server terminal. ## Next steps -- **Integrate into your agent** — wrap the three endpoints (`/add`, - `/flush`, `/search`) in a thin Python client (`httpx.AsyncClient`) and - call them from your agent loop. -- **App + project scope** — set `app_id` / `project_id` to anything - other than `"default"` to partition memory spaces inside one server. -- **Knowledge base** — upload documents (PDF / HTML / DOCX) via - `/api/v1/knowledge/documents` and search them with hybrid retrieval - at `/api/v1/knowledge/search`. Ships with a 20-category default - taxonomy. See [docs/knowledge.md](docs/knowledge.md). -- **Reflection** — offline memory self-improvement that consolidates - related episodes. Disabled by default; enable in `ome.toml` - (`[strategies.reflect_episodes] enabled = true`). Changes are - hot-reloaded, no server restart needed. -- **Multi-modal messages** — `messages[].content` accepts a list of - typed `ContentItem`s (`text` / `image` / `audio` / `doc` / `pdf` / - `html` / `email`) for non-text input. Install the optional extra - to enable parsing: - `uv pip install 'everos[multimodal]'`. Office documents - (`doc` / `docx` / `xls` / `ppt` / `…`) additionally need - **LibreOffice** on the host (`brew install --cask libreoffice` / - `apt-get install libreoffice`) — without it those uploads return - HTTP 503 (`CAPABILITY_UNAVAILABLE`); PDF / image / audio / HTML - still work. -- **Filter DSL and search modes** — `/search` supports a filter DSL - (`AND` / `OR` / scalar predicates) and four methods (`HYBRID` / - `KEYWORD` / `VECTOR` / `AGENTIC`). The OpenAPI docs UI is served at - `/docs` only when the server runs with `ENV=DEV`; the default (`prod`) - serves the API without the docs UI. The schema also lives at - [docs/openapi.json](docs/openapi.json). -- **Architecture** — see [docs/architecture.md](docs/architecture.md) - for the DDD layering and cascade design, and - [docs/storage_layout.md](docs/storage_layout.md) for the on-disk - layout. -- **Found a bug?** — open an issue (see [CONTRIBUTING.md](CONTRIBUTING.md); - external pull requests are not merged). +- **Integrate into your agent** — wrap `/add`, `/flush`, `/search` in a + thin HTTP client and call them from your agent loop. +- **App + project scope** — pass `app_id` / `project_id` in your API + requests to partition memory spaces inside one server (defaults to + `"default"` when omitted). +- **Knowledge base** — upload documents via + `/api/v1/knowledge/documents` and search with hybrid retrieval. See + [docs/knowledge.md](docs/knowledge.md). +- **Reflection** — offline memory consolidation; enable in `ome.toml`. + See [docs/reflection.md](docs/reflection.md). +- **Multimodal** — ingest image / pdf / audio / office documents. See + [docs/multimodal.md](docs/multimodal.md). +- **Search modes** — four methods (`HYBRID` / `KEYWORD` / `VECTOR` / + `AGENTIC`) with a filter DSL. See [docs/openapi.json](docs/openapi.json) + for the full API schema. +- **Architecture** — [docs/architecture.md](docs/architecture.md) for + DDD layering; [docs/storage_layout.md](docs/storage_layout.md) for + on-disk layout. +- **Found a bug?** — [open an issue](CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md index 487c8fd8a..60f6e22e3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -EverOS is released and in the v1.x stable line. Security fixes are applied to the +EverOS is released and at v1.0.0 (stable). Security fixes are applied to the latest release line only. | Version | Supported | @@ -39,9 +39,8 @@ following in mind: is loopback-only. Only set the bind to `0.0.0.0` (or any routable interface) after you have placed your own gateway / auth layer in front; `everos server start` will log a warning when you bind to `0.0.0.0`. -- Secrets (LLM / embedding / rerank API keys) normally live in your local - `/everos.toml`, or in `EVEROS_*` environment variables for container - deployments. Protect those values as you would any credential. EverOS never - transmits them anywhere except the providers you configure. +- Secrets (LLM / embedding API keys) live in your local `.env`; protect that + file as you would any credential. EverOS never transmits them anywhere except + the providers you configure. - Memory content is stored as plaintext `.md` files; apply OS-level file permissions or disk encryption if your data is sensitive. diff --git a/benchmarks/.env.example b/benchmarks/.env.example new file mode 100644 index 000000000..b8af07bb6 --- /dev/null +++ b/benchmarks/.env.example @@ -0,0 +1,6 @@ +# Provider connection info for Answer + Judge LLM phases. +# API keys are comma-separated for key rotation (round-robin failover). +ANSWER_API_KEY=sk-... +ANSWER_BASE_URL=https://openrouter.ai/api/v1 +JUDGE_API_KEY=sk-... +JUDGE_BASE_URL=https://openrouter.ai/api/v1 diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 000000000..416f683bf --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,338 @@ +# Running the LoCoMo Benchmark + +EverOS ships a self-contained runner for the +[LoCoMo](https://github.com/snap-research/locomo) (Long Conversation Memory) +benchmark ([Maharana et al., 2024](https://arxiv.org/abs/2402.17753)). +LoCoMo evaluates how well a memory system retrieves facts from long +multi-session dialogues across four question categories: **single-hop**, +**multi-hop**, **open-domain**, and **temporal**. This guide walks through +reproducing EverOS's LoCoMo retrieval scores locally. + +## Pipeline at a glance + +Each conversation runs through a four-stage pipeline: + +``` +ADD ──► wait_ready ──► SEARCH ──► ANSWER ──► JUDGE + │ │ │ │ │ + │ ingest msgs & query EverOS generate LLM-as-judge + │ flush per-session per QA pair answers majority vote + │ into EverOS from (judge_runs×) + │ context + ▼ + cascade + OME drain + (per-conv polling) +``` + +- **ADD** — sends LoCoMo sessions to EverOS (`/add` + `/flush`), then polls + cascade and OME queues until the conversation's data is fully indexed. +- **SEARCH** — queries EverOS `/search` for each QA question. +- **ANSWER** — feeds retrieved episodes to an LLM to generate an answer. +- **JUDGE** — an LLM judge scores each answer as CORRECT or WRONG against + the gold answer; runs `judge_runs` times per question and majority-votes. + +Stages are **independently re-runnable** — each reads from and writes to +JSONL files, so you can re-judge with a different model without re-ingesting +or re-searching. + +Multiple conversations run **in parallel** via `conversations_concurrency`. +Within each conversation, search and eval questions run concurrently via +`search_concurrency` and `eval_concurrency`. + +## Contents + +- [Prerequisites](#prerequisites) +- [Configuration](#configuration) +- [1. Prepare the dataset](#1-prepare-the-dataset) +- [2. Start the server](#2-start-the-server) +- [3. Run the benchmark](#3-run-the-benchmark) +- [4. Output](#4-output) +- [CLI reference](#cli-reference) +- [Notes](#notes) + +--- + +## Prerequisites + +- A working EverOS installation — complete **all steps** in + [QUICKSTART.md](../QUICKSTART.md) (configure providers, start server, + verify search works — not just `/health`) +- Python 3.12+ with `tqdm` installed (`pip install tqdm`) +- EverOS configured for chat-only extraction — in your `everos.toml`: + + ```toml + [memorize] + mode = "chat" + ``` + + And in `ome.toml`, disable strategies the benchmark does not use: + + ```toml + [strategies.extract_foresight] + enabled = false + + [strategies.extract_user_profile] + enabled = false + ``` + + This keeps episode extraction, `extract_atomic_facts`, and + `trigger_profile_clustering` (agentic search relies on clusters), + while cutting unnecessary LLM calls from foresight and profile + extraction. + +- Copy `benchmarks/.env.example` → `benchmarks/.env` and fill in your API + keys: + +```bash +cp benchmarks/.env.example benchmarks/.env +# Edit benchmarks/.env: +ANSWER_API_KEY=sk-... # LLM for generating answers +ANSWER_BASE_URL=https://openrouter.ai/api/v1 +JUDGE_API_KEY=sk-... # LLM for judging answers +JUDGE_BASE_URL=https://openrouter.ai/api/v1 +``` + +Keys are comma-separated for round-robin failover (e.g. +`ANSWER_API_KEY=sk-aaa,sk-bbb`). More keys raise the effective RPM +ceiling, which lets you increase `eval_concurrency` in `config.toml` for +faster answer/judge throughput. + +## Configuration + +The only required configuration is provider credentials — copy +`benchmarks/.env.example` → `benchmarks/.env` and fill in your API keys +(already done in [Prerequisites](#prerequisites)). + +Everything else has sensible defaults in `benchmarks/config.toml` — see +the comments in that file for tunable parameters. + +## 1. Prepare the dataset + +LoCoMo 10 contains 10 multi-session conversations (~50 sessions each, +~150 QA pairs per conversation across 4 categories, adversarial +category excluded). + +```bash +mkdir -p data +curl -o data/locomo10.json \ + https://raw.githubusercontent.com/snap-research/locomo/main/data/locomo10.json +``` + +## 2. Start the server + +Raise the file descriptor limit **before** starting — concurrent agentic +searches open many LanceDB segment files simultaneously (EverOS compacts +segments automatically, but burst concurrency during benchmark can exceed +the default macOS limit of 256): + +```bash +ulimit -n 10240 +everos server start [--root ] +``` + +> **Important:** if you use a custom `--root`, pass the same path to the +> benchmark runner via `--everos-root` — the runner polls the cascade and +> OME databases under that root to know when data is ready. A mismatch +> causes silent readiness false-positives. + +## 3. Run the benchmark + +All runs require `--run-name`, which becomes the `project_id` used for data +isolation (see [Run isolation](#run-isolation) below). + +**Smoke test first** — verify end-to-end connectivity before a full run: + +```bash +python benchmarks/run.py --run-name smoke --smoke [--everos-root ] +``` + +**Full run (all 10 conversations):** + +```bash +python benchmarks/run.py --run-name locomo-agentic [--everos-root ] +``` + +**Single conversation:** + +```bash +python benchmarks/run.py --run-name locomo-agentic --conv 0 [--everos-root ] +``` + +**Skip ingest, re-run search + answer + judge:** + +```bash +python benchmarks/run.py --run-name locomo-agentic --stages search answer judge +``` + +**Re-judge only (reuse existing answer JSONL):** + +```bash +python benchmarks/run.py --run-name locomo-agentic --stages judge +``` + +## 4. Output + +Output root is `benchmarks/results//`: + +``` +benchmarks/results// +├── run_spec.json # reproducibility snapshot (git hash, config, stages) +├── conv0/ +│ ├── search_.jsonl # per-question search results +│ ├── answer_.jsonl # per-question generated answers +│ ├── judge_.jsonl # per-question judge verdicts +│ └── error.log # only on failure — full traceback +├── conv1/ … conv9/ +├── report.json # aggregate accuracy by method + category +└── report.txt # human-readable accuracy table +``` + +`report.json` and `report.txt` are written after all conversations finish +(only when the `judge` stage is included). + +**Sample `report.txt`:** + +``` +================================================================ + EverOS LoCoMo Benchmark Report +================================================================ + +Run Info + Run name: locomo-agentic + Generated: 2026-06-28T14:30:00+00:00 + Git hash: abc1234 + EverOS version: 1.1.0 + Python: 3.12.11 + Conversations: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + Stages: ['add', 'search', 'answer', 'judge'] + +Configuration + Answer model: gpt-4.1-mini + Judge model: gpt-4o-mini + Judge runs: 3 + Top-k: 10 + Eval owner: speaker_a + +---------------------------------------------------------------- + Method: agentic +---------------------------------------------------------------- + + Max accuracy: 93.4% (best of 3 judge runs / mean / majority) + Majority: 93.3% (1437/1540) + Mean accuracy: 93.3% (avg across 3 judge runs) + + Per category: + 1. single-hop 94.0% (265/282) + 2. multi-hop 91.0% (292/321) + 3. open-domain 80.2% (77/96) + 4. temporal 95.5% (803/841) + + Per conversation: + conv0 93.4% (142/152) + conv1 96.3% (78/81) + ... + + Search: 1540 queries, avg 23.1s, p50 19.4s, max 142.3s + Answer: 1540 questions, avg 4.7s, 7,224,168 tokens + Judge: 1540 questions × 3 runs, 2,335,683 tokens, unanimous 95.2% + + Total tokens: 9,559,851 +``` + +## CLI reference + +| Flag | Default | Description | +|---|---|---| +| `--run-name` | *(required)* | Run name — maps to `project_id` for data isolation | +| `--conv` | `0 1 2 … 9` | Conversation indices to run | +| `--stages` | `add search answer judge` | Pipeline stages to execute | +| `--config` | `config` | TOML config name (without `.toml` extension) | +| `--base-url` | `http://localhost:8000` | EverOS server address | +| `--everos-root` | `~/.everos` | EverOS root path (for cascade/OME queue polling) | +| `--data-path` | `data/locomo10.json` | Path to LoCoMo dataset JSON | +| `--smoke` | off | Smoke mode: 2 convs, first 50 msgs each, 10 QA (stratified), `judge_runs=1` | + +## Notes + +### Evaluation methodology + +The runner uses an **LLM-as-Judge** approach: a judge LLM receives the +question, the gold answer, and the generated answer, then outputs `CORRECT` +or `WRONG`. Each question is judged `judge_runs` times (default 3); the +final verdict is a **majority vote**. Accuracy = correct / total per method +and per category. + +The four LoCoMo question categories test different retrieval capabilities: + +| Category | Name | Tests | +|---|---|---| +| 1 | single-hop | Direct fact retrieval from one episode | +| 2 | multi-hop | Reasoning across multiple episodes | +| 3 | open-domain | General knowledge grounded in conversation | +| 4 | temporal | Time-sensitive questions requiring date reasoning | + +Category 5 (adversarial — questions with no answer in the conversation) is +excluded from evaluation. + +### Run isolation + +Each benchmark run is scoped by three identifiers: + +| Scope | Value | Purpose | +|---|---|---| +| `app_id` | `locomo_benchmark` | Fixed; separates benchmark data from production | +| `project_id` | `--run-name` value | Per-experiment isolation | +| `owner_id` | `_conv` | Per-conversation memory partition | + +Two runs with the **same** `--run-name` share the same memory corpus — +useful when re-running later stages, but problematic if you want a clean +ingest. Use distinct names (e.g. `locomo-agentic`, `locomo-hybrid`) for +independent experiments. + +### Stage independence + +Each stage reads from and writes to JSONL files in `conv/`. This means: + +- `--stages search` reads from the EverOS server (requires prior `add`). +- `--stages answer` reads `search_.jsonl` (requires prior `search`). +- `--stages judge` reads `answer_.jsonl` (requires prior `answer`). + +You can swap the judge model and re-run `--stages judge` without touching +ingest or search. + +### Smoke mode + +`--smoke` is a **pipeline sanity check**, not a scored run. It forces: + +- 2 conversations (conv 0, 1) running in parallel +- First 50 messages each (across however many sessions that covers) +- 10 QA pairs per conversation, stratified-sampled to cover all categories +- `judge_runs=1` (no majority vote) + +Use it to verify end-to-end connectivity before committing to a full run. + +### Runtime estimates + +Rough estimates with default settings (varies by provider latency): + +| Scope | Time | Token cost (approx.) | +|---|---|---| +| Smoke | 2–5 min | ~80k tokens | +| Single conv (full) | 15–30 min | ~1M tokens | +| Full 10-conv run | 2–4 hours | ~10M tokens | + +The `add` + `wait_ready` phase dominates wall-clock time; LLM calls +(answer + judge) dominate token cost. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `Connection refused` on run | EverOS server not running | `everos server start` | +| `ANSWER_API_KEY not set` | Missing `.env` | Copy `.env.example` → `.env`, fill keys | +| `Timeout after 1800s` in wait_ready | Cascade/OME still processing | Increase `cascade_timeout` in config.toml or check server logs | +| `OME task(s) failed` warning | OME strategy crashed | Check `everos cascade status`; data may be incomplete | +| `Missing search_*.jsonl` | Running `answer` without prior `search` | Add `search` to `--stages` or run it first | +| `Too many open files (os error 24)` | LanceDB FD exhaustion from concurrent searches | Lower `search_concurrency` in config.toml (agentic needs more FDs per query) or raise `ulimit -n` | +| Low accuracy across all categories | Embedding/rerank not configured | Verify `everos.toml` has working embedding + rerank providers | +| `conv/error.log` exists | Unhandled exception in that conversation | Read the traceback; other conversations are unaffected | diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benchmarks/config.py b/benchmarks/config.py new file mode 100644 index 000000000..f7008266f --- /dev/null +++ b/benchmarks/config.py @@ -0,0 +1,157 @@ +"""Benchmark configuration. + +Frozen Pydantic model providing all tunable parameters for the LoCoMo +benchmark pipeline. Defaults are aligned with the upstream evaluation +reference so that numbers are directly comparable. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class BenchmarkConfig(BaseModel): + """Immutable benchmark configuration. + + Args: + cascade_timeout: Max seconds to wait for cascade queue to drain after flush. + batch_size: Messages per /add request. + methods: Comma-separated search methods. + top_k: Number of episodes to retrieve per question. + eval_owner: Which speaker's memory partition to query. + answer_model: LLM model for the Answer phase. + answer_temperature: Sampling temperature for answers. + answer_max_tokens: Max output tokens per answer call. + answer_timeout: Per-request timeout (seconds) for the answer LLM. + answer_max_retries: Retry budget for the answer phase. + judge_model: LLM model for the Judge phase. + judge_temperature: Sampling temperature for judging. + judge_timeout: Per-request timeout (seconds) for the judge LLM. + judge_max_retries: Retry budget for the judge phase. + judge_runs: Independent judge evaluations per question (majority vote). + conversations_concurrency: How many conversations run at the same time. + eval_concurrency: How many questions are processed in parallel within each conversation. + """ + + model_config = ConfigDict(frozen=True) + + # --- EverOS server --- + cascade_timeout: int = 7200 + batch_size: int = 25 + + # --- Search --- + methods: str = "agentic" + top_k: int = 10 + eval_owner: Literal["speaker_a", "speaker_b"] = "speaker_a" + + # --- Answer LLM --- + answer_model: str = "gpt-4.1-mini" + answer_temperature: float = 0.0 + answer_max_tokens: int = 32768 + answer_timeout: float = 300.0 + answer_max_retries: int = 5 + + # --- Judge LLM --- + judge_model: str = "gpt-4o-mini" + judge_temperature: float = 0.0 + judge_timeout: float = 300.0 + judge_max_retries: int = 5 + judge_runs: int = 3 + + # --- Concurrency --- + conversations_concurrency: int = 10 + eval_concurrency: int = 20 + search_concurrency: int = 5 + + @property + def parsed_methods(self) -> list[str]: + """Split comma-separated methods string into a list.""" + return [m.strip() for m in self.methods.split(",") if m.strip()] + + @classmethod + def from_toml( + cls, name: str = "config", *, config_dir: Path | None = None + ) -> BenchmarkConfig: + """Load config from a TOML file. + + Args: + name: Config name without .toml extension. + config_dir: Directory containing config files. + Falls back to ``benchmarks/`` relative to the repo root. + + Raises: + FileNotFoundError: When the TOML file does not exist. + """ + if config_dir is None: + config_dir = Path(__file__).parent + path = config_dir / f"{name}.toml" + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {path}") + with open(path, "rb") as f: + overrides = tomllib.load(f) + return cls(**overrides) + + +class SearchResult(BaseModel): + """One QA pair's search stage output.""" + + model_config = ConfigDict(frozen=True) + + index: int + question: str + golden_answer: str + category: int | None + evidence: list[str] + episodes: list[dict] + profiles: list[dict] + search_time_s: float + method: str + + +class AnswerResult(BaseModel): + """One QA pair's answer stage output.""" + + model_config = ConfigDict(frozen=True) + + index: int + question: str + golden_answer: str + category: int | None + generated_answer: str + answer_time_s: float + answer_attempts: int + answer_tokens: int = 0 + + +class JudgeResult(BaseModel): + """One QA pair's judge stage output.""" + + model_config = ConfigDict(frozen=True) + + index: int + question: str + golden_answer: str + generated_answer: str + category: int | None + is_correct: bool + judgments: list[bool] + judge_tokens: int = 0 + + +class RunSpec(BaseModel): + """Reproducibility snapshot serialized at run start.""" + + model_config = ConfigDict(frozen=True) + + run_name: str + config: dict + conversations: list[int] + stages: list[str] + git_hash: str + python_version: str + everos_version: str + started_at: str diff --git a/benchmarks/config.toml b/benchmarks/config.toml new file mode 100644 index 000000000..81a4f32dc --- /dev/null +++ b/benchmarks/config.toml @@ -0,0 +1,33 @@ +# Benchmark configuration — single source of truth. +# +# All parameters aligned with the upstream evaluation reference so that +# benchmark numbers are directly comparable to published LoCoMo results. +# BenchmarkConfig reads this file on every run. + +# --- EverOS server --- +cascade_timeout = 7200 # max seconds to wait for cascade + OME to complete +batch_size = 25 # messages per /add request (everos caps at 500) + +# --- Search --- +methods = "agentic" # comma-separated: keyword, vector, hybrid, agentic +top_k = 10 # episodes to retrieve per question +eval_owner = "speaker_a" # which speaker's memory partition to query + +# --- Answer LLM --- +answer_model = "gpt-4.1-mini" # LLM for generating answers from retrieved context +answer_temperature = 0.0 # deterministic answers +answer_max_tokens = 32768 # max output tokens per answer call +answer_timeout = 300.0 # per-request socket timeout (seconds) +answer_max_retries = 5 # retry budget (HTTP + empty-response retries) + +# --- Judge LLM --- +judge_model = "gpt-4o-mini" # LLM judge for correctness evaluation +judge_temperature = 0.0 # deterministic judging +judge_timeout = 300.0 # per-request socket timeout (seconds) +judge_max_retries = 5 # retry budget +judge_runs = 3 # majority vote across N independent judge runs + +# --- Concurrency --- +conversations_concurrency = 10 # how many conversations run at the same time +search_concurrency = 5 # concurrent /search requests per conversation +eval_concurrency = 20 # concurrent answer/judge LLM calls per conversation diff --git a/benchmarks/run.py b/benchmarks/run.py new file mode 100644 index 000000000..a6e869ae4 --- /dev/null +++ b/benchmarks/run.py @@ -0,0 +1,1840 @@ +"""EverOS LoCoMo Benchmark Runner — typed pipeline with JSONL I/O. + +Per-conversation pipeline: ADD -> wait_ready -> SEARCH -> ANSWER -> JUDGE. +Multiple conversations run in parallel via ThreadPoolExecutor. + +Usage: + python benchmarks/run.py --run-name baseline-v1 + python benchmarks/run.py --run-name baseline-v1 --smoke + python benchmarks/run.py --run-name baseline-v1 --stages search answer judge +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import json +import os +import platform +import re +import shutil +import sqlite3 +import statistics +import subprocess +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import openai +import requests +from dotenv import load_dotenv + +# Ensure the repo root is on sys.path when run as a script +# (python benchmarks/run.py) rather than as a module (python -m benchmarks.run). +_repo_root = str(Path(__file__).resolve().parent.parent) +if _repo_root not in sys.path: + sys.path.insert(0, _repo_root) + +from tqdm import tqdm as _tqdm # noqa: E402 + +from benchmarks.config import ( # noqa: E402 + AnswerResult, + BenchmarkConfig, + JudgeResult, + RunSpec, + SearchResult, +) + +_BAR_WIDTH = 16 +_IS_TTY = sys.stdout.isatty() +_FILL_BG = "\033[44m" if _IS_TTY else "" +_EMPTY_BG = "\033[48;5;237m" if _IS_TTY else "" +_RESET = "\033[0m" if _IS_TTY else "" + + +class _ColorBarTqdm(_tqdm): + """tqdm subclass with fixed fill + background colors.""" + + @staticmethod + def format_meter( # type: ignore[override] + n, + total, + elapsed, + ncols=None, + prefix="", + ascii=False, + unit="it", + unit_scale=False, + rate=None, + bar_format=None, + postfix=None, + unit_divisor=1000, + initial=0, + colour=None, + **extra_kwargs, + ) -> str: + if bar_format and bar_format == "{desc}": + return prefix + + frac = n / total if total else 0 + filled = int(_BAR_WIDTH * frac) + empty = _BAR_WIDTH - filled + bar = f"{_FILL_BG}{' ' * filled}{_EMPTY_BG}{' ' * empty}{_RESET}" + + pct = f"{frac * 100:3.0f}%" + + elapsed_str = _tqdm.format_interval(elapsed) + rate_val = n / elapsed if elapsed and n else 0 + remaining = (total - n) / rate_val if rate_val and total else 0 + remaining_str = _tqdm.format_interval(remaining) if total else "?" + + return f"{prefix} {pct} {bar} {n}/{total} [{elapsed_str}<{remaining_str}]" + + +# ============================================================================= +# Inline prompts (originally from everosos-opensource evaluation/) +# ============================================================================= + +ANSWER_PROMPT = """ +You are an intelligent memory assistant tasked with retrieving accurate information from episodic memories. + +# CONTEXT: +You have access to episodic memories from conversations between two speakers. These memories contain +timestamped information that may be relevant to answering the question. + +# INSTRUCTIONS: +Your goal is to synthesize information from all relevant memories to provide a comprehensive and accurate answer. +You MUST follow a structured Chain-of-Thought process to ensure no details are missed. +Actively look for connections between people, places, and events to build a complete picture. Synthesize information from different memories to answer the user's question. +It is CRITICAL that you move beyond simple fact extraction and perform logical inference. When the evidence strongly suggests a connection, you must state that connection. Do not dismiss reasonable inferences as "speculation." Your task is to provide the most complete answer supported by the available evidence. + +# CRITICAL REQUIREMENTS: +1. NEVER omit specific names - use "Amy's colleague Rob" not "a colleague" +2. ALWAYS include exact numbers, amounts, prices, percentages, dates, times +3. PRESERVE frequencies exactly - "every Tuesday and Thursday" not "twice a week" +4. MAINTAIN all proper nouns and entities as they appear +5. EXPLICITLY state confidence levels for inferences (High/Medium/Low) + +# RESPONSE FORMAT (You MUST follow this structure): + +## STEP 1: RELEVANT MEMORIES EXTRACTION +[List each memory that relates to the question, with its timestamp] +- Memory [ID]: [timestamp] - [content snippet] + +## STEP 2: KEY INFORMATION IDENTIFICATION +[Extract ALL specific details from the memories] +- Names mentioned: [list all person names, place names, company names] +- Numbers/Quantities: [list all amounts, prices, percentages] +- Dates/Times: [list all temporal information] +- Frequencies: [list any recurring patterns] +- Other entities: [list brands, products, etc.] + +## STEP 3: CROSS-MEMORY LINKING & INFERENCE +[Identify entities that appear in multiple memories and link related information. Make reasonable inferences when entities are strongly connected.] +- Shared entities: [list people, places, events mentioned across different memories] +- Connections found: [e.g., "Memory 1 mentions A moved from hometown -> Memory 2 mentions A's hometown is LA -> Therefore A moved from LA"] +- Inferences: [Connect the dots. Label confidence: (Confidence: High/Medium/Low)] + +## STEP 4: TIME REFERENCE CALCULATION +[If applicable, convert relative time references using the timestamps] +- Original reference: [e.g., "last year" from May 2022] +- Calculation: [Show logic] +- Actual time: [e.g., "2021"] + +## STEP 5: CONTRADICTION & GAP ANALYSIS +[Check for conflicts and missing details] +- Conflicting information: [describe conflicts and resolution strategy] +- Missing information: [explicitly state what details are requested but missing from context] + +## STEP 6: DETAIL VERIFICATION CHECKLIST +- [ ] All person names included? +- [ ] All locations included? +- [ ] All numbers exact? +- [ ] All frequencies specific? +- [ ] All dates/times precise? +- [ ] All proper nouns preserved? + +## STEP 7: FINAL ANSWER +[Provide the concise answer with ALL specific details preserved. Do not include the internal checklist in this section, just the final synthesized answer.] + +--- + +{context} + +Question: {question} + +Now, follow the Chain-of-Thought process above to answer the question: +""" + +JUDGE_SYSTEM_PROMPT = "You are an expert grader that determines if answers to questions match a gold standard answer" + +JUDGE_USER_PROMPT = """Your task is to label an answer to a question as 'CORRECT' or 'WRONG'. You will be given the following data: + (1) a question (posed by one user to another user), + (2) a 'gold' (ground truth) answer, + (3) a generated answer +which you will score as CORRECT/WRONG. + +The point of the question is to ask about something one user should know about the other user based on their prior conversations. +The gold answer will usually be a concise and short answer that includes the referenced topic, for example: +Question: Do you remember what I got the last time I went to Hawaii? +Gold answer: A shell necklace +The generated answer might be much longer, but you should be generous with your grading - as long as it touches on the same topic as the gold answer, it should be counted as CORRECT. + +For time related questions, the gold answer will be a specific date, month, year, etc. The generated answer might be much longer or use relative time references (like "last Tuesday" or "next month"), but you should be generous with your grading - as long as it refers to the same date or time period as the gold answer, it should be counted as CORRECT. Even if the format differs (e.g., "May 7th" vs "7 May"), consider it CORRECT if it's the same date. + +Now it's time for the real question: +Question: {question} +Gold answer: {golden_answer} +Generated answer: {generated_answer} + +First, provide a short (one sentence) explanation of your reasoning, then finish with CORRECT or WRONG. +Do NOT include both CORRECT and WRONG in your response, or it will break the evaluation script. + +Just return the label CORRECT or WRONG in a json format with the key as "label". +""" + +# ============================================================================= +# Category labels +# ============================================================================= + +CATEGORY_NAMES: dict[int, str] = { + 1: "single-hop", + 2: "multi-hop", + 3: "open-domain", + 4: "temporal", +} + +# ============================================================================= +# Minimal HTTP client for everos (single-tenant, no auth headers) +# ============================================================================= + + +class EverosClient: + """Minimal HTTP client for everos's /api/v1/memory/* endpoints.""" + + def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 600): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def post(self, path: str, data: dict[str, Any]) -> tuple[int, dict]: + full_url = f"{self.base_url}{path}" + resp = requests.post( + full_url, + json=data, + headers={"Content-Type": "application/json"}, + timeout=(10, self.timeout), + ) + try: + return resp.status_code, resp.json() + except ValueError: + return resp.status_code, {} + + +def print_section(title: str) -> None: + """Print a section header.""" + print(f"\n{'=' * 72}") + print(f" {title}") + print(f"{'=' * 72}") + + +# ============================================================================= +# LLM client pool -- round-robin across multiple API keys with 429 failover +# ============================================================================= + + +def _split_keys(s: str) -> list[str]: + """Split a comma-separated key string into a list of stripped non-empty keys.""" + return [k.strip() for k in s.split(",") if k.strip()] + + +class _PoolCompletions: + def __init__(self, pool: LLMClientPool): + self._pool = pool + + def create(self, **kwargs: Any) -> Any: + return self._pool._create_with_failover(**kwargs) + + +class _PoolChat: + def __init__(self, pool: LLMClientPool): + self.completions = _PoolCompletions(pool) + + +class LLMClientPool: + """Round-robin pool of openai.OpenAI clients with RateLimitError failover. + + Duck-types openai.OpenAI: callers may use ``pool.chat.completions.create(...)`` + transparently. On RateLimitError, the next key in the pool is tried; after + all keys are exhausted, the last error is re-raised. Other errors propagate + immediately (they're not "this key is throttled" signals). + + When ``base_url`` points to OpenRouter, the pool injects + ``extra_body={"provider": {"only": [...]}}`` on every request so the + downstream provider is fixed. OpenRouter otherwise routes freely across + providers (OpenAI, Azure, Fireworks, ...), which on a 1.5k-question batch + eventually lands on a region-restricted Azure instance and 403s every + later request. The allow-list defaults to ``["openai"]`` and can be + overridden via the ``OPENROUTER_PROVIDER_ONLY`` env var (comma-separated, + e.g. ``openai,fireworks``). + """ + + def __init__(self, api_keys: list[str], base_url: str, **kwargs: Any): + if not api_keys: + raise ValueError("LLMClientPool: at least one API key required") + self._clients = [ + openai.OpenAI(api_key=k, base_url=base_url, **kwargs) for k in api_keys + ] + self._idx = 0 + self._lock = threading.Lock() + self.key_count = len(self._clients) + self.chat = _PoolChat(self) + self._provider_constraint = self._resolve_provider_constraint(base_url) + + @staticmethod + def _resolve_provider_constraint(base_url: str) -> dict[str, Any] | None: + """Resolve the OpenRouter ``provider`` extra-body block (or None).""" + if "openrouter" not in (base_url or "").lower(): + return None + raw = os.getenv("OPENROUTER_PROVIDER_ONLY", "openai").strip() + if not raw or raw.lower() == "any": + return None + only = [p.strip() for p in raw.split(",") if p.strip()] + return {"only": only, "allow_fallbacks": False} + + def _next_client(self) -> openai.OpenAI: + with self._lock: + c = self._clients[self._idx] + self._idx = (self._idx + 1) % len(self._clients) + return c + + def _create_with_failover(self, **kwargs: Any) -> Any: + if self._provider_constraint is not None: + extra = dict(kwargs.get("extra_body") or {}) + extra.setdefault("provider", self._provider_constraint) + kwargs["extra_body"] = extra + last_err: Exception | None = None + for _ in range(len(self._clients)): + client = self._next_client() + try: + return client.chat.completions.create(**kwargs) + except openai.RateLimitError as e: + last_err = e + _tqdm.write( + f" [warn] RateLimitError, rotating key " + f"({_ + 1}/{len(self._clients)})" + ) + continue + assert last_err is not None + raise last_err + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _stratified_sample(qa_list: list[dict], *, n: int = 10) -> list[dict]: + """Pick up to *n* QA items evenly across all categories present. + + Round-robins across categories so each gets roughly ``n / num_cats`` + items. Preserves original order within each category. + """ + by_cat: dict[int, list[dict]] = {} + for qa in qa_list: + cat = qa.get("category") + if cat is not None: + by_cat.setdefault(cat, []).append(qa) + + selected: list[dict] = [] + while len(selected) < n: + picked_any = False + for cat in sorted(by_cat): + if len(selected) >= n: + break + if by_cat[cat]: + selected.append(by_cat[cat].pop(0)) + picked_any = True + if not picked_any: + break + + # Restore original order + order = {id(qa): i for i, qa in enumerate(qa_list)} + selected.sort(key=lambda q: order[id(q)]) + return selected + + +def _check_failures(raw: list) -> None: + """Raise if any element in *raw* is an exception from ``_parallel_map``.""" + errors = [(i, item) for i, item in enumerate(raw) if isinstance(item, Exception)] + if not errors: + return + if len(errors) == 1: + raise errors[0][1] + msg = f"{len(errors)} failures:\n" + "\n".join(f" [{i}] {e}" for i, e in errors) + raise RuntimeError(msg) from errors[0][1] + + +def _parallel_map( + items: list, + worker, + *, + concurrency: int, + pbar: _tqdm | None = None, +) -> list: + """Run ``worker(i, item)`` over *items* concurrently; preserve input order. + + Updates *pbar* on each completion if provided. Falls back to serial + execution when *concurrency* <= 1. + """ + results: list = [None] * len(items) + + if concurrency <= 1: + for i, item in enumerate(items): + results[i] = worker(i, item) + if pbar: + pbar.update(1) + return results + + with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool: + future_to_idx: dict[concurrent.futures.Future, int] = { + pool.submit(worker, i, item): i for i, item in enumerate(items) + } + for fut in concurrent.futures.as_completed(future_to_idx): + idx = future_to_idx[fut] + try: + results[idx] = fut.result() + except Exception as exc: # noqa: BLE001 + results[idx] = exc + if pbar: + pbar.update(1) + + return results + + +# ============================================================================= +# Wait for cascade + OME drain +# ============================================================================= + + +def _poll_cascade(db_path: Path, conv_pattern: str) -> tuple[int, int]: + """Return (total, pending) for cascade md_change_state rows.""" + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + total, pending = conn.execute( + "SELECT COUNT(*), " + "SUM(CASE WHEN status IN ('pending','processing') THEN 1 ELSE 0 END) " + "FROM md_change_state WHERE md_path LIKE ?", + (conv_pattern,), + ).fetchone() + conn.close() + return total, pending + + +def _poll_ome( + ome_db_path: Path, + since: str, + ome_filter: str, + ome_params: tuple[str, ...], +) -> tuple[int, int, int]: + """Return (total, pending, failed) for OME run_record rows.""" + if not ome_db_path.exists(): + return 0, 0, 0 + conn = sqlite3.connect(f"file:{ome_db_path}?mode=ro", uri=True) + rows = conn.execute( + "SELECT status, COUNT(*) FROM run_record " + f"WHERE started_at >= ? AND {ome_filter} " + "GROUP BY status", + (since, *ome_params), + ).fetchall() + conn.close() + total, pending, failed = 0, 0, 0 + for status, count in rows: + total += count + if status == "running": + pending += count + elif status in ("failed", "dead_letter", "crashed"): + failed += count + return total, pending, failed + + +def _wait_ready( + everos_root: str, + conv_index: int, + project_id: str, + timeout_s: int, + poll_interval_s: float = 3.0, + *, + since: str = "", + pbar: _tqdm | None = None, +) -> None: + """Wait until cascade queue AND OME jobs finish for a conversation.""" + root = Path(everos_root).expanduser() + db_path = root / ".index" / "sqlite" / "system.db" + ome_db_path = root / ".index" / "sqlite" / "ome.db" + conv_pattern = f"%/{project_id}/%_conv{conv_index}/%" + + if not db_path.exists(): + raise RuntimeError( + f"Cascade DB not found at {db_path} — " + f"is --everos-root ({everos_root}) correct? " + f"It must match the server's --root." + ) + + ome_filter = ( + "json_extract(event_payload, '$.app_id') = 'locomo_benchmark' " + "AND json_extract(event_payload, '$.project_id') = ? " + "AND (" + " json_extract(event_payload, '$.owner_id') LIKE ? " + " OR json_extract(event_payload, '$.session_id') LIKE ?" + ")" + ) + ome_params = (project_id, f"%_conv{conv_index}", f"locomo_conv{conv_index}_%") + + deadline = time.time() + timeout_s + stable_count = 0 + cascade_pending = 0 + ome_pending = 0 + + while time.time() < deadline: + cascade_total, cascade_pending = _poll_cascade(db_path, conv_pattern) + ome_total, ome_pending, ome_failed = _poll_ome( + ome_db_path, since, ome_filter, ome_params + ) + + if ome_failed > 0: + raise RuntimeError( + f"{ome_failed} OME task(s) failed for conv {conv_index} " + "— data is incomplete, aborting" + ) + + if pbar is not None: + done = (cascade_total - cascade_pending) + (ome_total - ome_pending) + total = cascade_total + ome_total + if total > 0: + pbar.total = total + pbar.n = done + pbar.refresh() + + if cascade_pending == 0 and ome_pending == 0: + stable_count += 1 + if stable_count >= 2: + if pbar is not None and pbar.total and pbar.total > 0: + pbar.n = pbar.total + pbar.refresh() + return + else: + stable_count = 0 + + time.sleep(poll_interval_s) + + raise RuntimeError( + f"Timeout after {timeout_s}s waiting for conv {conv_index} " + f"(cascade_pending={cascade_pending}, ome_running={ome_pending}) " + f"— increase cascade_timeout in config.toml" + ) + + +# ============================================================================= +# Data loading -- preserve LoCoMo session_N structure for per-session flushing +# ============================================================================= + + +def _parse_session_timestamp(ts_str: str) -> int: + """Parse LoCoMo timestamp string to epoch milliseconds. + + Format examples: "1:56 pm on 8 May, 2023", "12:09 am on 13 September, 2023". + + LoCoMo's raw timestamps carry no timezone, so we pin them to UTC -- + matching ``everalgo/benchmarks/datasets/locomo/loader.py:_parse_timestamp``. + Without an explicit tz, ``naive_dt.timestamp()`` would shift epochs by + the OS's local-vs-UTC offset, so the same dataset would produce + different absolute timestamps on different machines. + """ + dt = datetime.strptime(ts_str.strip(), "%I:%M %p on %d %B, %Y") + return int(dt.replace(tzinfo=UTC).timestamp() * 1000) + + +def load_conversation( + data_path: str, conv_index: int +) -> tuple[list[dict], list[dict], str, str]: + """Load a LoCoMo conversation, preserving session_N boundaries. + + Returns (sessions, qa_list, speaker_a, speaker_b) where ``sessions`` is + a list of {session_idx, messages} ordered by session_idx. Each message + carries dia_id / speaker / text / timestamp_ms. QA list excludes + category 5 (adversarial). + """ + with open(data_path, encoding="utf-8") as f: + dataset = json.load(f) + + if conv_index < 0 or conv_index >= len(dataset): + raise ValueError( + f"conv_index {conv_index} out of range " + f"(dataset has {len(dataset)} conversations, valid: 0..{len(dataset) - 1})" + ) + + conv = dataset[conv_index] + conversation = conv["conversation"] + speaker_a = conversation["speaker_a"] + speaker_b = conversation["speaker_b"] + + sessions: list[dict] = [] + session_idx = 1 + while True: + session_key = f"session_{session_idx}" + dt_key = f"session_{session_idx}_date_time" + if dt_key not in conversation: + break + if session_key in conversation: + ts_str = conversation[dt_key] + base_ts_ms = _parse_session_timestamp(ts_str) + session_msgs = conversation[session_key] + if isinstance(session_msgs, list): + msgs: list[dict] = [] + for i, msg in enumerate(session_msgs): + if not msg.get("text"): + continue # skip image-only messages + msgs.append( + { + "dia_id": msg["dia_id"], + "speaker": msg["speaker"], + "text": msg["text"], + "timestamp_ms": base_ts_ms + i * 30000, + } + ) + if msgs: + sessions.append({"session_idx": session_idx, "messages": msgs}) + session_idx += 1 + + qa_list = [q for q in conv.get("qa", []) if q.get("category") != 5] + return sessions, qa_list, speaker_a, speaker_b + + +# ============================================================================= +# JSONL I/O helpers +# ============================================================================= + + +def _write_jsonl(path: Path, items: list) -> None: + """Write a list of Pydantic models (or dicts) to a JSONL file.""" + with open(path, "w", encoding="utf-8") as f: + for item in items: + if hasattr(item, "model_dump_json"): + f.write(item.model_dump_json()) + else: + f.write(json.dumps(item, ensure_ascii=False, default=str)) + f.write("\n") + + +def _read_jsonl(path: Path, model_cls: type) -> list: + """Read a JSONL file into a list of Pydantic model instances.""" + results = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + results.append(model_cls.model_validate_json(line)) + return results + + +# ============================================================================= +# Add phase -- one everos session_id per LoCoMo session, flush after each +# ============================================================================= + + +def run_add_phase( + client: EverosClient, + sessions: list[dict], + conv_index: int, + batch_size: int, + *, + app_id: str, + project_id: str, + pbar: _tqdm | None = None, +) -> None: + """Send each LoCoMo session to its own everos session_id and flush.""" + for sess in sessions: + session_id = f"locomo_conv{conv_index}_s{sess['session_idx']}" + api_messages: list[dict] = [ + { + "sender_id": f"{msg['speaker'].lower()}_conv{conv_index}", + "sender_name": msg["speaker"], + "role": "user", + "timestamp": msg["timestamp_ms"], + "content": [{"type": "text", "text": msg["text"]}], + } + for msg in sess["messages"] + ] + + batches = [ + api_messages[i : i + batch_size] + for i in range(0, len(api_messages), batch_size) + ] + for idx, batch in enumerate(batches): + payload = { + "session_id": session_id, + "app_id": app_id, + "project_id": project_id, + "messages": batch, + } + status, resp = client.post("/api/v1/memory/add", payload) + assert status == 200, ( + f"Add (session_id={session_id}, batch {idx + 1}) failed: " + f"status={status} resp={resp}" + ) + flush_status, flush_resp = client.post( + "/api/v1/memory/flush", + {"session_id": session_id, "app_id": app_id, "project_id": project_id}, + ) + assert flush_status == 200, ( + f"Flush (session_id={session_id}) failed: " + f"status={flush_status} resp={flush_resp}" + ) + if pbar: + pbar.update(len(sess["messages"])) + + +# ============================================================================= +# Search phase -- single-owner partition +# ============================================================================= + + +_SEARCH_RETRIES = 3 + + +def _search_one( + i: int, + qa: dict, + *, + client: EverosClient, + method: str, + top_k: int, + owner_id: str, + app_id: str, + project_id: str, +) -> SearchResult: + """Search a single QA question with retry on server errors.""" + question = qa["question"] + payload: dict = { + "query": question, + "method": method, + "top_k": top_k, + "user_id": owner_id, + "app_id": app_id, + "project_id": project_id, + } + resp: dict = {} + search_time = 0.0 + for attempt in range(_SEARCH_RETRIES): + t0 = time.perf_counter() + status, resp = client.post("/api/v1/memory/search", payload) + search_time = time.perf_counter() - t0 + + if status == 200: + break + error_detail = resp.get("detail", resp) if isinstance(resp, dict) else resp + last_err = RuntimeError( + f"Search failed for question {i}: status={status} detail={error_detail}" + ) + if status < 500 or attempt >= _SEARCH_RETRIES - 1: + raise last_err + wait = 2.0 * (2**attempt) + _tqdm.write( + f" [warn] search retry {attempt + 1}/{_SEARCH_RETRIES} " + f"(question {i}): status={status}, backoff {wait:.0f}s" + ) + time.sleep(wait) + + data = resp.get("data", {}) + episodes = data.get("episodes", []) + profiles = data.get("profiles", []) + return SearchResult( + index=i, + question=question, + golden_answer=str(qa["answer"]), + category=qa.get("category"), + evidence=qa.get("evidence", []), + episodes=episodes, + profiles=profiles, + search_time_s=round(search_time, 4), + method=method, + ) + + +def run_search_phase( + client: EverosClient, + qa_list: list[dict], + owner_id: str, + method: str, + top_k: int, + app_id: str, + project_id: str, + conv_dir: Path, + config: BenchmarkConfig, + *, + method_label: str, + pbar: _tqdm | None = None, +) -> list[SearchResult]: + """Search for each QA question and write results to JSONL.""" + + def _worker(i: int, qa: dict) -> SearchResult: + return _search_one( + i, + qa, + client=client, + method=method, + top_k=top_k, + owner_id=owner_id, + app_id=app_id, + project_id=project_id, + ) + + raw = _parallel_map( + qa_list, + _worker, + concurrency=config.search_concurrency, + pbar=pbar, + ) + + _check_failures(raw) + results: list[SearchResult] = raw # type: ignore[assignment] + + out_path = conv_dir / f"search_{method_label}.jsonl" + _write_jsonl(out_path, results) + return results + + +# ============================================================================= +# Answer phase +# ============================================================================= + + +_CONTEXT_TEMPLATE = """Episodes memories for conversation between {speaker_a} and {speaker_b}: + + {episodes} +""" + + +def _build_context( + episodes: list[dict], profiles: list[dict], speaker_a: str, speaker_b: str +) -> str: + """Build context string from search results. + + Matches the benchmark's context format: each episode renders as + ``{subject}: {episode_text}\\n---`` with double-newline separators. + Profile memories are intentionally omitted (benchmark doesn't use them). + """ + episode_lines = [ + f"{ep.get('subject', 'N/A')}: " + f"{ep.get('episode') or ep.get('summary') or ep.get('content') or 'N/A'}\n---" + for ep in episodes + ] + return _CONTEXT_TEMPLATE.format( + speaker_a=speaker_a, + speaker_b=speaker_b, + episodes="\n\n".join(episode_lines), + ) + + +def _extract_final_answer(text: str) -> str: + """Extract the final answer using a 3-marker priority chain. + + Matches the benchmark's extraction logic: + 1. ``## STEP 7: FINAL ANSWER`` (prompt STEP 7 section header) + 2. ``FINAL ANSWER:`` (colon-suffixed) + 3. ``FINAL ANSWER`` (bare -- leading colon stripped if present) + + Each marker uses ``rsplit`` to take the LAST occurrence (handles marker + appearing in reasoning prose before the actual answer). + """ + result = text.strip() + for marker in ("## STEP 7: FINAL ANSWER", "FINAL ANSWER:", "FINAL ANSWER"): + if marker in result: + answer = result.rsplit(marker, 1)[1].strip() + # Bare "FINAL ANSWER" may have a leading ":" -- strip it + if marker == "FINAL ANSWER" and answer.startswith(":"): + answer = answer[1:].strip() + return answer + return result + + +def _answer_one( + i: int, + sr: SearchResult, + *, + speaker_a: str, + speaker_b: str, + llm_client: LLMClientPool, + llm_model: str, + config: BenchmarkConfig, +) -> AnswerResult: + """Generate an answer for a single search result; safe to run in a thread. + + Retries up to config.answer_max_retries times. LLM parameters (temperature, + max_tokens, timeout) come from config so they can be tuned without touching + the code. + """ + context = _build_context(sr.episodes, sr.profiles, speaker_a, speaker_b) + prompt = ANSWER_PROMPT.format(context=context, question=sr.question) + + t0 = time.perf_counter() + raw_answer = "" + generated_answer = "" + attempts_used = 0 + total_tokens = 0 + for attempt in range(config.answer_max_retries): + attempts_used = attempt + 1 + try: + r = llm_client.chat.completions.create( + model=llm_model, + messages=[{"role": "user", "content": prompt}], + temperature=config.answer_temperature, + max_tokens=config.answer_max_tokens, + timeout=config.answer_timeout, + ) + raw_answer = r.choices[0].message.content or "" + if hasattr(r, "usage") and r.usage is not None: + total_tokens += r.usage.total_tokens or 0 + except Exception as e: + cause = f" <- {e.__cause__}" if e.__cause__ else "" + if attempt < config.answer_max_retries - 1: + wait = 1.0 * (2**attempt) + _tqdm.write( + f" [warn] answer retry {attempt + 1}/{config.answer_max_retries} " + f"(question {sr.index}): {e}{cause}, backoff {wait:.0f}s" + ) + time.sleep(wait) + continue + raise RuntimeError( + f"Answer failed after {config.answer_max_retries} retries " + f"(question {sr.index}): {e}{cause}" + ) from e + + generated_answer = _extract_final_answer(raw_answer) + if generated_answer.strip(): + break + if attempt < config.answer_max_retries - 1: + wait = 1.0 * (2**attempt) + _tqdm.write( + f" [warn] answer empty, retry " + f"{attempt + 1}/{config.answer_max_retries} " + f"(q{sr.index}), backoff {wait:.0f}s" + ) + time.sleep(wait) + + if not generated_answer.strip(): + raise RuntimeError( + f"Answer empty after {config.answer_max_retries} retries " + f"(question {sr.index})" + ) + + answer_time = time.perf_counter() - t0 + return AnswerResult( + index=sr.index, + question=sr.question, + golden_answer=sr.golden_answer, + category=sr.category, + generated_answer=generated_answer, + answer_time_s=round(answer_time, 4), + answer_attempts=attempts_used, + answer_tokens=total_tokens, + ) + + +def run_answer_phase( + search_path: Path, + speaker_a: str, + speaker_b: str, + llm_client: LLMClientPool, + config: BenchmarkConfig, + conv_dir: Path, + *, + method_label: str, + pbar: _tqdm | None = None, +) -> list[AnswerResult]: + """Read search JSONL, generate answers, write answer JSONL.""" + search_results = _read_jsonl(search_path, SearchResult) + + def _worker(i: int, sr: SearchResult) -> AnswerResult: + return _answer_one( + i, + sr, + speaker_a=speaker_a, + speaker_b=speaker_b, + llm_client=llm_client, + llm_model=config.answer_model, + config=config, + ) + + raw = _parallel_map( + search_results, + _worker, + concurrency=config.eval_concurrency, + pbar=pbar, + ) + + _check_failures(raw) + results: list[AnswerResult] = raw # type: ignore[assignment] + + out_path = conv_dir / f"answer_{method_label}.jsonl" + _write_jsonl(out_path, results) + return results + + +# ============================================================================= +# Evaluate phase -- LLM-as-Judge +# ============================================================================= + + +def _extract_json(content: str) -> str | None: + """Robustly extract JSON from LLM response.""" + m = re.search(r"```(?:json)?\s*(\{[^`]*\})\s*```", content, re.DOTALL) + if m: + return m.group(1).strip() + m = re.search(r'\{[^{}]*"label"\s*:\s*"[^"]*"[^{}]*\}', content) + if m: + return m.group(0) + return content.strip() + + +def _judge_single( + llm_client: LLMClientPool, + llm_model: str, + question: str, + golden_answer: str, + generated_answer: str, + config: BenchmarkConfig, +) -> tuple[bool, int]: + """Judge a single answer. Returns (is_correct, tokens_used). + + Retries up to config.judge_max_retries times on any error (API failures, + JSON parse errors, missing label) with exponential backoff. Raises on + exhaustion — benchmark results are unusable with missing judgments. + """ + user_prompt = JUDGE_USER_PROMPT.format( + question=question, + golden_answer=golden_answer, + generated_answer=generated_answer, + ) + last_err: Exception | None = None + for attempt in range(config.judge_max_retries): + try: + r = llm_client.chat.completions.create( + model=llm_model, + messages=[ + {"role": "system", "content": JUDGE_SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + temperature=config.judge_temperature, + timeout=config.judge_timeout, + ) + tokens = 0 + if hasattr(r, "usage") and r.usage is not None: + tokens = r.usage.total_tokens or 0 + + content = r.choices[0].message.content or "" + json_str = _extract_json(content) + if not json_str: + raise ValueError("Empty JSON from judge response") + result = json.loads(json_str) + label = result.get("label", "").strip().upper() + if label not in ("CORRECT", "WRONG"): + raise ValueError(f"Unknown judge label: {label!r}") + return label == "CORRECT", tokens + except Exception as e: # noqa: BLE001 + last_err = e + cause = f" <- {e.__cause__}" if e.__cause__ else "" + if attempt < config.judge_max_retries - 1: + wait = 0.5 * (2**attempt) + _tqdm.write( + f" [warn] judge retry {attempt + 1}/{config.judge_max_retries}: " + f"{e}{cause}, backoff {wait:.1f}s" + ) + time.sleep(wait) + continue + raise RuntimeError( + f"Judge failed after {config.judge_max_retries} retries " + f"(question: {question!r}, golden: {golden_answer!r})" + ) from last_err + + +def _evaluate_one( + i: int, + ar: AnswerResult, + *, + llm_client: LLMClientPool, + llm_model: str, + judge_runs: int, + config: BenchmarkConfig, +) -> JudgeResult: + """Evaluate a single answer result with majority-vote judging.""" + judgments: list[bool] = [] + total_tokens = 0 + for _ in range(judge_runs): + is_correct, tokens = _judge_single( + llm_client, + llm_model, + ar.question, + ar.golden_answer, + ar.generated_answer, + config=config, + ) + judgments.append(is_correct) + total_tokens += tokens + + correct = sum(judgments) > judge_runs / 2 + return JudgeResult( + index=ar.index, + question=ar.question, + golden_answer=ar.golden_answer, + generated_answer=ar.generated_answer, + category=ar.category, + is_correct=correct, + judgments=judgments, + judge_tokens=total_tokens, + ) + + +def run_evaluate_phase( + answer_path: Path, + judge_client: LLMClientPool, + config: BenchmarkConfig, + judge_runs: int, + conv_dir: Path, + *, + method_label: str, + pbar: _tqdm | None = None, +) -> list[JudgeResult]: + """Read answer JSONL, judge answers, write judge JSONL.""" + answer_results = _read_jsonl(answer_path, AnswerResult) + + def _worker(i: int, ar: AnswerResult) -> JudgeResult: + return _evaluate_one( + i, + ar, + llm_client=judge_client, + llm_model=config.judge_model, + judge_runs=judge_runs, + config=config, + ) + + raw = _parallel_map( + answer_results, + _worker, + concurrency=config.eval_concurrency, + pbar=pbar, + ) + + _check_failures(raw) + results: list[JudgeResult] = raw # type: ignore[assignment] + + out_path = conv_dir / f"judge_{method_label}.jsonl" + _write_jsonl(out_path, results) + return results + + +# ============================================================================= +# Reporting +# ============================================================================= + + +def _pct(n: int, total: int) -> str: + if total == 0: + return "N/A" + return f"{n / total * 100:.1f}%" + + +def _collect_method_summary( + method: str, + output_dir: Path, + conversations: list[int], + config: BenchmarkConfig, +) -> dict[str, Any] | None: + """Load per-conv results and compute accuracy/latency stats for one method.""" + all_search: list[SearchResult] = [] + all_answer: list[AnswerResult] = [] + all_judge: list[JudgeResult] = [] + conv_accuracy: dict[int, dict[str, int]] = {} + + for conv_idx in conversations: + conv_dir = output_dir / f"conv{conv_idx}" + search_p = conv_dir / f"search_{method}.jsonl" + answer_p = conv_dir / f"answer_{method}.jsonl" + judge_p = conv_dir / f"judge_{method}.jsonl" + + if search_p.exists(): + all_search.extend(_read_jsonl(search_p, SearchResult)) + if answer_p.exists(): + all_answer.extend(_read_jsonl(answer_p, AnswerResult)) + if judge_p.exists(): + conv_judges = _read_jsonl(judge_p, JudgeResult) + all_judge.extend(conv_judges) + c = sum(1 for r in conv_judges if r.is_correct) + conv_accuracy[conv_idx] = {"correct": c, "total": len(conv_judges)} + else: + print(f" [report] skip conv{conv_idx}/{method} -- no judge JSONL") + + if not all_judge: + return None + + total = len(all_judge) + correct = sum(1 for r in all_judge if r.is_correct) + + cat_stats: dict[int, dict[str, int]] = {} + for r in all_judge: + cat = r.category + if cat is None: + continue + if cat not in cat_stats: + cat_stats[cat] = {"correct": 0, "total": 0} + cat_stats[cat]["total"] += 1 + if r.is_correct: + cat_stats[cat]["correct"] += 1 + + actual_judge_runs = max( + (len(r.judgments) for r in all_judge if r.judgments), default=config.judge_runs + ) + + per_run_correct = [0] * actual_judge_runs + per_run_total = 0 + for r in all_judge: + if len(r.judgments) >= actual_judge_runs: + per_run_total += 1 + for ri in range(actual_judge_runs): + if r.judgments[ri]: + per_run_correct[ri] += 1 + per_run_accuracies = ( + [c / per_run_total for c in per_run_correct] if per_run_total > 0 else [] + ) + + mean_accuracy = ( + round(statistics.mean(per_run_accuracies), 4) if per_run_accuracies else 0 + ) + overall_accuracy = correct / total if total else 0 + all_candidates = per_run_accuracies + [mean_accuracy, overall_accuracy] + max_accuracy = round(max(all_candidates), 4) if all_candidates else 0 + + search_times = [r.search_time_s for r in all_search] + answer_times = [r.answer_time_s for r in all_answer] + answer_tokens = sum(r.answer_tokens for r in all_answer) + answer_retries = sum( + r.answer_attempts - 1 for r in all_answer if r.answer_attempts > 1 + ) + judge_tokens = sum(r.judge_tokens for r in all_judge) + judge_agreements = sum( + 1 + for r in all_judge + if r.judgments and all(j == r.judgments[0] for j in r.judgments) + ) + + return { + "method": method, + "total": total, + "correct": correct, + "accuracy": round(correct / total, 4) if total else 0, + "mean_accuracy": mean_accuracy, + "max_accuracy": max_accuracy, + "per_run_accuracies": [round(a, 4) for a in per_run_accuracies], + "category_stats": { + str(k): {"correct": v["correct"], "total": v["total"]} + for k, v in sorted(cat_stats.items()) + }, + "per_conversation": { + str(k): {"correct": v["correct"], "total": v["total"]} + for k, v in sorted(conv_accuracy.items()) + }, + "search": { + "count": len(all_search), + "avg_latency_s": round(statistics.mean(search_times), 3) + if search_times + else 0, + "p50_latency_s": round(statistics.median(search_times), 3) + if search_times + else 0, + "max_latency_s": round(max(search_times), 3) if search_times else 0, + }, + "answer": { + "count": len(all_answer), + "avg_latency_s": round(statistics.mean(answer_times), 3) + if answer_times + else 0, + "total_tokens": answer_tokens, + "retries": answer_retries, + }, + "judge": { + "count": len(all_judge), + "total_tokens": judge_tokens, + "judge_runs": actual_judge_runs, + "unanimous_rate": round(judge_agreements / total, 3) if total else 0, + }, + } + + +def _write_report_txt( + txt_path: Path, + all_summaries: dict[str, dict[str, Any]], + conversations: list[int], + config: BenchmarkConfig, + run_spec: dict[str, Any], + duration_str: str, +) -> None: + """Write the human-readable report.txt.""" + generated = datetime.now(UTC) + with open(txt_path, "w", encoding="utf-8") as f: + f.write("=" * 64 + "\n") + f.write(" EverOS LoCoMo Benchmark Report\n") + f.write("=" * 64 + "\n\n") + + f.write("Run Info\n") + f.write(f" Run name: {run_spec.get('run_name', 'N/A')}\n") + f.write(f" Generated: {generated.isoformat()}\n") + if duration_str: + f.write(f" Duration: {duration_str}\n") + f.write(f" Git hash: {run_spec.get('git_hash', 'N/A')}\n") + f.write(f" EverOS version: {run_spec.get('everos_version', 'N/A')}\n") + f.write(f" Python: {run_spec.get('python_version', 'N/A')}\n") + f.write(f" Conversations: {conversations}\n") + f.write(f" Stages: {run_spec.get('stages', 'N/A')}\n\n") + + f.write("Configuration\n") + f.write(f" Answer model: {config.answer_model}\n") + f.write(f" Judge model: {config.judge_model}\n") + f.write(f" Judge runs: {config.judge_runs} (config)\n") + f.write(f" Top-k: {config.top_k}\n") + f.write(f" Eval owner: {config.eval_owner}\n\n") + + for method, s in all_summaries.items(): + f.write("-" * 64 + "\n") + f.write(f" Method: {method}\n") + f.write("-" * 64 + "\n\n") + + jr = s["judge"]["judge_runs"] + f.write( + f" Max accuracy: {s['max_accuracy'] * 100:.1f}% " + f"(best of {jr} judge runs / mean / majority)\n" + ) + f.write( + f" Majority: {_pct(s['correct'], s['total'])} " + f"({s['correct']}/{s['total']})\n" + ) + f.write( + f" Mean accuracy: {s['mean_accuracy'] * 100:.1f}% " + f"(avg across {jr} judge runs)\n\n" + ) + + f.write(" Per category:\n") + for cat_key, cs in sorted(s["category_stats"].items()): + cat_int = int(cat_key) + label = CATEGORY_NAMES.get(cat_int, f"cat-{cat_key}") + f.write( + f" {cat_key}. {label:<14s} " + f"{_pct(cs['correct'], cs['total']):>6s} " + f"({cs['correct']}/{cs['total']})\n" + ) + + f.write("\n Per conversation:\n") + for conv_key, cv in sorted(s["per_conversation"].items()): + f.write( + f" conv{conv_key:<4s} " + f"{_pct(cv['correct'], cv['total']):>6s} " + f"({cv['correct']}/{cv['total']})\n" + ) + + ss = s["search"] + f.write( + f"\n Search: {ss['count']} queries, " + f"avg {ss['avg_latency_s']}s, " + f"p50 {ss['p50_latency_s']}s, " + f"max {ss['max_latency_s']}s\n" + ) + + ans = s["answer"] + f.write( + f" Answer: {ans['count']} questions, " + f"avg {ans['avg_latency_s']}s, " + f"{ans['total_tokens']:,} tokens" + ) + if ans["retries"]: + f.write(f", {ans['retries']} retries") + f.write("\n") + + js = s["judge"] + unan = _pct( + int(js["unanimous_rate"] * js["count"]), js["count"] + ) + f.write( + f" Judge: {js['count']} questions" + f" × {js['judge_runs']} runs, " + f"{js['total_tokens']:,} tokens, " + f"unanimous {unan}\n" + ) + + total_tokens = ans["total_tokens"] + js["total_tokens"] + f.write(f"\n Total tokens: {total_tokens:,}\n\n") + + +def _print_terminal_summary( + all_summaries: dict[str, dict[str, Any]], + output_dir: Path, + duration_str: str, +) -> None: + """Print condensed results to the terminal.""" + for method, s in all_summaries.items(): + print(f"\n{'=' * 64}") + print(f" Method: {method}") + jr = s["judge"]["judge_runs"] + max_pct = f"{s['max_accuracy'] * 100:.1f}%" + mean_pct = f"{s['mean_accuracy'] * 100:.1f}%" + maj = _pct(s["correct"], s["total"]) + print(f" Max: {max_pct} (best of {jr} runs)") + print(f" Majority:{maj:>6s} ({s['correct']}/{s['total']})") + print(f" Mean: {mean_pct} (avg of {jr} runs)") + for cat_key, cs in sorted(s["category_stats"].items()): + cat_int = int(cat_key) + label = CATEGORY_NAMES.get(cat_int, f"cat-{cat_key}") + acc = _pct(cs["correct"], cs["total"]) + n, t = cs["correct"], cs["total"] + print(f" {cat_key}. {label:<14s} {acc:>6s} ({n}/{t})") + ss, ans, js = s["search"], s["answer"], s["judge"] + total_tokens = ans["total_tokens"] + js["total_tokens"] + print(f" Search: avg {ss['avg_latency_s']}s, p50 {ss['p50_latency_s']}s") + a_tok = ans["total_tokens"] + j_tok = js["total_tokens"] + print( + f" Tokens: {total_tokens:,} " + f"(answer {a_tok:,} + judge {j_tok:,})" + ) + if duration_str: + print(f" Duration: {duration_str}") + print(f"{'=' * 64}") + print(f"\n Reports: {output_dir / 'report.json'}, {output_dir / 'report.txt'}") + + +def aggregate_report( + output_dir: Path, + conversations: list[int], + config: BenchmarkConfig, +) -> None: + """Aggregate search/answer/judge results and write report files.""" + all_summaries: dict[str, dict[str, Any]] = {} + for method in config.parsed_methods: + summary = _collect_method_summary(method, output_dir, conversations, config) + if summary is not None: + all_summaries[method] = summary + + report_path = output_dir / "report.json" + with open(report_path, "w", encoding="utf-8") as f: + json.dump(all_summaries, f, indent=2, ensure_ascii=False) + + run_spec_path = output_dir / "run_spec.json" + run_spec: dict[str, Any] = {} + if run_spec_path.exists(): + with open(run_spec_path, encoding="utf-8") as f: + run_spec = json.load(f) + + duration_str = "" + started_str = run_spec.get("started_at", "") + if started_str: + started = datetime.fromisoformat(started_str) + duration = datetime.now(UTC) - started + hours, rem = divmod(int(duration.total_seconds()), 3600) + mins, secs = divmod(rem, 60) + duration_str = f"{hours}h {mins}m {secs}s" + + _write_report_txt( + output_dir / "report.txt", + all_summaries, + conversations, + config, + run_spec, + duration_str, + ) + _print_terminal_summary(all_summaries, output_dir, duration_str) + + +# ============================================================================= +# Run spec +# ============================================================================= + + +def _get_everos_version() -> str: + """Return the installed everos package version, or 'unknown'.""" + try: + from importlib.metadata import version as _pkg_version + + return _pkg_version("everos") + except Exception: + return "unknown" + + +def _write_run_spec( + output_dir: Path, + run_name: str, + config: BenchmarkConfig, + conversations: list[int], + stages: list[str], +) -> None: + """Write reproducibility snapshot to run_spec.json.""" + git_hash = "unknown" + try: # noqa: SIM105 + git_hash = subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError): + pass + + spec = RunSpec( + run_name=run_name, + config=config.model_dump(), + conversations=conversations, + stages=stages, + git_hash=git_hash, + python_version=platform.python_version(), + everos_version=_get_everos_version(), + started_at=datetime.now(UTC).isoformat(), + ) + (output_dir / "run_spec.json").write_text( + spec.model_dump_json(indent=2), encoding="utf-8" + ) + + +# ============================================================================= +# Per-conversation orchestrator +# ============================================================================= + + +def run_conversation( + conv_index: int, + *, + args: argparse.Namespace, + config: BenchmarkConfig, + stages: list[str], + answer_client: LLMClientPool, + judge_client: LLMClientPool, + data_path: str, + position: int | None = None, + output_dir: Path, +) -> _tqdm: + """Run the full pipeline for a single conversation.""" + conv_dir = output_dir / f"conv{conv_index}" + if "add" in stages and conv_dir.exists(): + shutil.rmtree(conv_dir) + conv_dir.mkdir(parents=True, exist_ok=True) + + sessions, qa_list, spk_a, spk_b = load_conversation(data_path, conv_index) + + judge_runs = config.judge_runs + if args.smoke: + trimmed: list[dict] = [] + msg_count = 0 + for sess in sessions: + if msg_count >= 50: + break + remaining = 50 - msg_count + if len(sess["messages"]) <= remaining: + trimmed.append(sess) + msg_count += len(sess["messages"]) + else: + trimmed.append({**sess, "messages": sess["messages"][:remaining]}) + msg_count += remaining + sessions = trimmed + qa_list = _stratified_sample(qa_list, n=10) + judge_runs = 1 + + app_id = "locomo_benchmark" + project_id = args.run_name + _speaker = spk_a if config.eval_owner == "speaker_a" else spk_b + owner_id = f"{_speaker.lower()}_conv{conv_index}" + client = EverosClient(base_url=args.base_url) + + methods = config.parsed_methods + label = f"conv{conv_index}" + + total_stages = len(stages) + stage_num = 0 + + pbar = _ColorBarTqdm( + total=0, + desc=f"{label:<6s} init", + unit="it", + dynamic_ncols=True, + position=position, + leave=False, + ) + + def _stage(name: str, total: int, suffix: str = "") -> None: + nonlocal stage_num + stage_num += 1 + pbar.reset(total=total) + tag = f"{name} {suffix}".rstrip() + pbar.set_description_str(f"{label:<6s} {stage_num}/{total_stages} {tag:<15s}") + + if "add" in stages: + add_started = datetime.now(UTC).isoformat() + _stage("add", sum(len(s["messages"]) for s in sessions), "sending") + run_add_phase( + client, + sessions, + conv_index, + config.batch_size, + app_id=app_id, + project_id=project_id, + pbar=pbar, + ) + pbar.reset(total=0) + pbar.set_description_str( + f"{label:<6s} {stage_num}/{total_stages} {'add processing':<15s}" + ) + _wait_ready( + args.everos_root, + conv_index, + project_id, + config.cascade_timeout, + since=add_started, + pbar=pbar, + ) + + for method in methods: + if "search" in stages: + _stage("search", len(qa_list)) + run_search_phase( + client, + qa_list, + owner_id, + method, + config.top_k, + app_id, + project_id, + conv_dir, + config, + method_label=method, + pbar=pbar, + ) + + if "answer" in stages: + search_path = conv_dir / f"search_{method}.jsonl" + if not search_path.exists(): + raise FileNotFoundError( + f"Missing {search_path} -- run 'search' stage first" + ) + _stage("answer", len(_read_jsonl(search_path, SearchResult))) + run_answer_phase( + search_path, + spk_a, + spk_b, + answer_client, + config, + conv_dir, + method_label=method, + pbar=pbar, + ) + + if "judge" in stages: + answer_path = conv_dir / f"answer_{method}.jsonl" + if not answer_path.exists(): + raise FileNotFoundError( + f"Missing {answer_path} -- run 'answer' stage first" + ) + _stage("judge", len(_read_jsonl(answer_path, AnswerResult))) + run_evaluate_phase( + answer_path, + judge_client, + config, + judge_runs, + conv_dir, + method_label=method, + pbar=pbar, + ) + + pbar.bar_format = "{desc}" + pbar.set_description_str(f"{label:<6s} {total_stages}/{total_stages} done") + pbar.refresh() + return pbar + + +# ============================================================================= +# CLI +# ============================================================================= + + +def parse_args() -> tuple[argparse.Namespace, BenchmarkConfig]: + """Parse CLI args and load benchmark config.""" + p = argparse.ArgumentParser( + prog="benchmarks/run.py", + description="EverOS LoCoMo Benchmark Runner", + ) + p.add_argument( + "--run-name", + required=True, + help="Run name used as project_id for cross-run isolation.", + ) + p.add_argument( + "--conv", + type=int, + nargs="+", + default=list(range(10)), + help="Conversation indices (default: 0..9)", + ) + p.add_argument( + "--stages", + nargs="+", + default=["add", "search", "answer", "judge"], + choices=["add", "search", "answer", "judge"], + help="Pipeline stages to run (default: all)", + ) + p.add_argument( + "--config", + default="config", + help="Config TOML name without extension (default: config)", + ) + p.add_argument( + "--base-url", + default="http://localhost:8000", + help="EverOS server address", + ) + p.add_argument( + "--everos-root", + default=str(Path("~/.everos").expanduser()), + help="EverOS --root path for cascade polling", + ) + p.add_argument( + "--data-path", + default="data/locomo10.json", + help="Path to LoCoMo dataset", + ) + p.add_argument( + "--smoke", + action="store_true", + help="Smoke: 2 convs, 50 msgs, 10 QA, judge_runs=1", + ) + + args = p.parse_args() + config = BenchmarkConfig.from_toml(args.config) + + supported = ("keyword", "vector", "hybrid", "agentic") + bad = [m for m in config.parsed_methods if m not in supported] + if bad: + p.error(f"unsupported method(s) in config.toml: {bad}; supported: {supported}") + + if args.smoke: + args.conv = [0, 1] + + return args, config + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> None: + """Entry point: orchestrate all conversations.""" + args, config = parse_args() + + load_dotenv(Path(__file__).parent / ".env") + + answer_api_keys = _split_keys(os.getenv("ANSWER_API_KEY", "")) + answer_base_url = os.getenv("ANSWER_BASE_URL", "https://api.openai.com/v1") + judge_api_keys = _split_keys(os.getenv("JUDGE_API_KEY", "")) + judge_base_url = os.getenv("JUDGE_BASE_URL", "https://api.openai.com/v1") + + if not answer_api_keys: + print("ERROR: ANSWER_API_KEY not set in benchmarks/.env") + sys.exit(1) + if not judge_api_keys: + print("ERROR: JUDGE_API_KEY not set in benchmarks/.env") + sys.exit(1) + + answer_client = LLMClientPool( + answer_api_keys, + base_url=answer_base_url, + timeout=60, + max_retries=1, + ) + if answer_base_url == judge_base_url and answer_api_keys == judge_api_keys: + judge_client = answer_client + else: + judge_client = LLMClientPool( + judge_api_keys, + base_url=judge_base_url, + timeout=60, + max_retries=1, + ) + + output_dir = Path("benchmarks/results") / args.run_name + output_dir.mkdir(parents=True, exist_ok=True) + + _write_run_spec(output_dir, args.run_name, config, args.conv, args.stages) + + print( + f" Answer LLM: {config.answer_model} @ {answer_base_url}" + f" ({answer_client.key_count} keys)" + ) + print( + f" Judge LLM: {config.judge_model} @ {judge_base_url}" + f" ({judge_client.key_count} keys)" + ) + print(f" Search mode: {config.methods}") + print(f" Conversations: {args.conv}") + print(f" Stages: {args.stages}") + print(f" Output: {output_dir}") + + conv_positions = {ci: pos for pos, ci in enumerate(args.conv)} + + conv_errors: dict[int, str] = {} + conv_pbars: dict[int, _tqdm] = {} + + def _run_conv(conv_index: int) -> bool: + try: + pbar = run_conversation( + conv_index, + args=args, + config=config, + stages=args.stages, + answer_client=answer_client, + judge_client=judge_client, + data_path=args.data_path, + output_dir=output_dir, + position=conv_positions[conv_index], + ) + conv_pbars[conv_index] = pbar + return True + except Exception as e: + import traceback + + tb = traceback.format_exc() + conv_dir = output_dir / f"conv{conv_index}" + conv_dir.mkdir(parents=True, exist_ok=True) + (conv_dir / "error.log").write_text(tb, encoding="utf-8") + conv_errors[conv_index] = str(e) + _tqdm.write(f" conv{conv_index} FAILED: {e}") + return False + + with ThreadPoolExecutor(max_workers=config.conversations_concurrency) as pool: + futures = {pool.submit(_run_conv, ci): ci for ci in args.conv} + results = {ci: f.result() for f, ci in futures.items()} + + for pbar in conv_pbars.values(): + pbar.leave = True + pbar.close() + + failed = [ci for ci, ok in results.items() if not ok] + if failed: + print(f"\n{len(failed)} conversation(s) failed: {failed}") + for ci in failed: + print(f" see {output_dir}/conv{ci}/error.log") + + # Aggregate + if "judge" in args.stages: + aggregate_report(output_dir, args.conv, config) + + print(f"\nDone. Results: {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/docs/api.md b/docs/api.md index 5750aa34a..b76ca99fb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -688,7 +688,7 @@ ranked; `score` is `null`. Ignored when `agent_id` is set. **`enable_llm_rerank`** — Opt-in LLM rerank pass for `method: "hybrid"`. Applies to `agent_case` and `agent_skill` fusion -only; the episode hierarchy path has built-in fact eviction and +only; the episode hybrid path has built-in fact eviction and ignores this flag. Adds one LLM call per request. Ignored by `keyword` / `vector` (no fusion to rerank) and `agentic` (uses its own cross-encoder loop). diff --git a/docs/architecture.md b/docs/architecture.md index 72d3bb51d..afb5efe30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -282,5 +282,5 @@ prevent external types from leaking into upper layers: ## Further reading - [docs/overview.md](overview.md) — vision and scope -- [docs/engineering.md](engineering.md) — contributor engineering reference: build, test, CI, conventions +- [docs/engineering.md](engineering.md) — engineering tooling and CI / CD - [.claude/rules/architecture.md](../.claude/rules/architecture.md) — short-form rules for Claude Code diff --git a/docs/configuration.md b/docs/configuration.md index 22e2c4cde..98fdf3479 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -105,7 +105,7 @@ everos init --root /data/everos |---|---|---|---|---| | `model` | string | `"gpt-4.1-mini"` | No | LLM model identifier. | | `api_key` | string | — | **Yes** | API key for the LLM provider. | -| `base_url` | string | — | **Yes** | Endpoint URL (OpenAI-compatible). | +| `base_url` | string | — | No | Custom endpoint URL (OpenAI-compatible). | ### `[multimodal]` @@ -113,7 +113,7 @@ everos init --root /data/everos |---|---|---|---|---| | `model` | string | `"google/gemini-3-flash-preview"` | No | Multimodal parsing model. | | `api_key` | string | — | **Yes** | API key. | -| `base_url` | string | — | **Yes** | Endpoint URL. | +| `base_url` | string | — | No | Custom endpoint URL. | | `max_concurrency` | int | `4` | No | Max parallel parsing requests. | | `file_uri_allow_dirs` | list[string] | `[]` | No | Allowlisted base dirs for `file://` URIs. Empty = allow any readable file. | | `file_uri_max_bytes` | int | `52428800` | No | Max size (bytes) of a `file://` asset; larger files are rejected. | @@ -134,7 +134,7 @@ everos init --root /data/everos | Field | Type | Default | Required | Description | |---|---|---|---|---| -| `provider` | string | inferred | No | Rerank provider: `deepinfra`, `vllm`, or `dashscope`. | +| `provider` | string | `"deepinfra"` | No | Rerank provider: `deepinfra` or `vllm`. | | `model` | string | — | **Yes** | Reranker model identifier. | | `api_key` | string | — | **Yes** | API key. | | `base_url` | string | — | **Yes** | Rerank endpoint URL. | @@ -164,12 +164,6 @@ everos init --root /data/everos | `threshold` | float | `0.65` | Cosine similarity threshold for clustering (0–1). | | `time_window_days` | float | `7.0` | Max age gap between cluster members. | -### `[search]` - -| Field | Type | Default | Description | -|---|---|---|---| -| `vector_strategy` | string | `"maxsim_atomic"` | Vector retrieval path: `maxsim_atomic` (finer-grained) or `episode` (legacy). | - ### `[knowledge]` | Field | Type | Default | Description | @@ -210,13 +204,9 @@ variables directly: ENV EVEROS_ROOT=/data/everos ENV EVEROS_LLM__API_KEY=sk-... ENV EVEROS_LLM__MODEL=gpt-4o -ENV EVEROS_LLM__BASE_URL=https://api.openai.com/v1 ENV EVEROS_EMBEDDING__MODEL=text-embedding-3-large ENV EVEROS_EMBEDDING__API_KEY=sk-... ENV EVEROS_EMBEDDING__BASE_URL=https://api.openai.com/v1 -ENV EVEROS_RERANK__MODEL=Qwen/Qwen3-Reranker-4B -ENV EVEROS_RERANK__API_KEY=... -ENV EVEROS_RERANK__BASE_URL=https://api.deepinfra.com/v1/inference ENV EVEROS_API__HOST=0.0.0.0 ENV EVEROS_API__PORT=8000 ``` diff --git a/docs/github-sync.md b/docs/github-sync.md index 3a2aa9cc3..cdb08ee3d 100644 --- a/docs/github-sync.md +++ b/docs/github-sync.md @@ -1,77 +1,156 @@ # GitHub Sync Guard -This page records the GitHub-only files that must be preserved when refreshing -this public repository from the internal GitLab source/export. - -## Rule - -When a GitLab export, archive, rsync, or scripted copy is used to refresh the -GitHub repository, do **not** blindly overwrite GitHub-only contributor, -automation, and public-facing workflow files. - -These files describe the public GitHub workflow: protected `main`, scoped -branches, GitHub pull requests, GitHub Actions, and community contribution -rules. Internal workflow text can mislead contributors and AI coding tools if -it is copied into GitHub. - -## Preserve These GitHub Files - -Keep the GitHub versions of these files unless a maintainer deliberately updates -them for the public GitHub workflow: - -- `CLAUDE.md` -- `CONTRIBUTING.md` -- `.github/PULL_REQUEST_TEMPLATE.md` -- `.github/BRANCH_PROTECTION.md` -- `.github/workflows/ci.yml` -- `.github/workflows/docs.yml` -- `.github/workflows/commits.yml` -- `.github/ISSUE_TEMPLATE/**` -- `.claude/skills/commit/SKILL.md` -- `.claude/skills/new-branch/SKILL.md` -- `.claude/skills/pr/SKILL.md` +This page records the rules for refreshing the public GitHub repository +(EverMind-AI/EverOS) from the internal GitLab source. + +## Core Principle + +GitLab `dev` is the source of truth. GitHub `main` is the public release +mirror. The two repos have **no shared commit history** — GitHub receives +packaged exports, not git pushes. + +--- + +## 1. Must Delete on GitHub + +These files exist on GitHub but are obsolete — delete them during sync: + +| File | Reason | +|---|---| +| `docs/locomo_benchmark.md` | Replaced by `benchmarks/README.md` | +| `tests/run_locomo_10x3.sh` | Replaced by `benchmarks/run.py` | +| `tests/run_locomo_batch.sh` | Replaced by `benchmarks/run.py` | +| `tests/run_locomo_full.sh` | Replaced by `benchmarks/run.py` | +| `tests/test_locomo.py` | Replaced by `benchmarks/run.py` | +| `src/everos/memory/strategies/_partition_locks.py` | Wrong path; correct location is `src/everos/memory/_partition_locks.py` | + +## 2. Must Preserve on GitHub (Do NOT Overwrite) + +These files have GitHub-specific content (branch model, PR workflow, +community-facing text). Keep the GitHub versions: + +### Workflow & CI + +- `CLAUDE.md` — says `main` branch, not `dev`/`master` +- `CONTRIBUTING.md` — says "curated PR contributions", not "no external PRs" +- `.github/PULL_REQUEST_TEMPLATE.md` — GitHub PR template +- `.github/BRANCH_PROTECTION.md` — GitHub branch protection rules +- `.github/workflows/ci.yml` — targets `main`, uses `actions/checkout@v6` +- `.github/workflows/docs.yml` — GitHub-only doc checks +- `.github/workflows/commits.yml` — GitHub-only commit lint +- `.github/ISSUE_TEMPLATE/**` — `.yml` format (GitLab uses `.md`) +- `.github/dependabot.yml` — GitHub-only dependency scanning + +### Claude Code rules & skills + +- `.claude/rules/*.md` — GitHub versions are simplified for contributors +- `.claude/skills/commit/SKILL.md` — Conventional Commits (vs Gitmoji) +- `.claude/skills/new-branch/SKILL.md` — branches from `main` (vs `dev`) +- `.claude/skills/pr/SKILL.md` — GitHub PR (vs GitLab MR) +- `.claude/settings.json` — GitHub permission set (includes `gh pr`) + +### GitHub-only scripts & tests + +- `scripts/check_commit_messages.py` +- `scripts/check_deprecated_names.py` +- `scripts/check_docs.py` - `scripts/check_github_contributor_docs.py` -- `tests/unit/test_scripts/test_check_github_contributor_docs.py` +- `scripts/check_pr_title.py` +- `scripts/check_repo_assets.py` +- `tests/unit/test_scripts/test_check_*.py` -## Safe Sync Pattern +### Root docs (GitHub version has different content) + +- `README.md` — GitHub version has banner, badges, use-case gallery +- `Makefile` — GitHub version has extra targets (docs-check, package, etc.) -Prefer an explicit exclude list in the sync job rather than manual cleanup after -the overwrite. Example shape: +## 3. Overwrite from GitLab + +Everything not in sections 1 or 2 should be overwritten from GitLab, +including but not limited to: + +- All `src/everos/**` source code +- All `tests/unit/**` and `tests/integration/**` and `tests/e2e/**` +- `pyproject.toml`, `uv.lock` +- `QUICKSTART.md`, `SECURITY.md` +- `CHANGELOG.md`, `CITATION.md`, `ACKNOWLEDGMENTS.md` +- `docs/*.md` (except those listed in Must Delete) +- `docs/openapi.json` +- `benchmarks/**` +- `.gitignore` +- `.gitlab-ci.yml` (harmless on GitHub, good to keep in sync) + +## 4. Do NOT Sync to GitHub + +These files are GitLab-internal and should not appear on GitHub: + +| File | Reason | +|---|---| +| `.gitlab/merge_request_templates/` | GitLab-only | +| `.vscode/` | IDE preference | +| `.claude/skills/release/SKILL.md` | Internal release workflow | +| `local/` | Temporary design docs | +| `evaluation/` | Empty module | + +## 5. Do NOT Sync to GitLab + +These files are GitHub-only and should stay only on GitHub: + +| Category | Files | +|---|---| +| use-cases/ | 90 files (claude-code-plugin, game-of-throne-demo, openher) | +| GitHub CI scripts | `scripts/check_*.py` + tests | +| GitHub CI workflows | `commits.yml`, `docs.yml` | +| Config | `.gitlint`, `.env.example` (root level) | +| Docs | `docs/use-cases.md` | + +## Safe Sync Pattern ```bash +# 1. Clone both repos +git clone gitlab-export +git clone github-checkout + +# 2. Sync with excludes rsync -a --delete \ + --exclude '.git' \ --exclude 'CLAUDE.md' \ --exclude 'CONTRIBUTING.md' \ - --exclude '.github/PULL_REQUEST_TEMPLATE.md' \ - --exclude '.github/BRANCH_PROTECTION.md' \ - --exclude '.github/workflows/ci.yml' \ - --exclude '.github/workflows/docs.yml' \ - --exclude '.github/workflows/commits.yml' \ - --exclude '.github/ISSUE_TEMPLATE/' \ - --exclude '.claude/skills/commit/SKILL.md' \ - --exclude '.claude/skills/new-branch/SKILL.md' \ - --exclude '.claude/skills/pr/SKILL.md' \ - --exclude 'scripts/check_github_contributor_docs.py' \ - --exclude 'tests/unit/test_scripts/test_check_github_contributor_docs.py' \ - / / -``` - -After any sync, run: - -```bash -make docs-check + --exclude 'README.md' \ + --exclude 'Makefile' \ + --exclude '.github/' \ + --exclude '.claude/rules/' \ + --exclude '.claude/skills/' \ + --exclude '.claude/settings.json' \ + --exclude 'scripts/check_*.py' \ + --exclude 'tests/unit/test_scripts/' \ + --exclude 'use-cases/' \ + --exclude 'local/' \ + --exclude 'evaluation/' \ + --exclude '.vscode/' \ + --exclude '.gitlab/' \ + --exclude '.gitlab-ci.yml' \ + gitlab-export/ github-checkout/ + +# 3. Delete obsolete files +cd github-checkout +rm -f docs/locomo_benchmark.md +rm -f tests/run_locomo_10x3.sh tests/run_locomo_batch.sh tests/run_locomo_full.sh +rm -f tests/test_locomo.py +rm -f src/everos/memory/strategies/_partition_locks.py + +# 4. Verify make lint +make test ``` -The `check_github_contributor_docs.py` gate catches the known failure mode: -public contributor docs drifting back to an internal branch/review model. - ## Review Checklist -Before opening a sync PR: +Before opening the sync PR on GitHub: -- Confirm `CLAUDE.md` says branches are created from `main`. -- Confirm `.claude/skills/pr/SKILL.md` creates PRs with `--base main`. -- Confirm `CONTRIBUTING.md` says GitHub pull request, not internal review - terminology. -- Confirm `make docs-check` and `make lint` both pass. +- [ ] `CLAUDE.md` says branches are created from `main` +- [ ] `.claude/skills/pr/SKILL.md` creates PRs with `--base main` +- [ ] `CONTRIBUTING.md` says "GitHub pull request", not internal terminology +- [ ] Obsolete files from section 1 are deleted +- [ ] `make lint` and `make test` pass +- [ ] No `.gitlab-ci.yml`, `.vscode/`, `local/`, `evaluation/` leaked in diff --git a/docs/index.md b/docs/index.md index 367df8cd3..466a2d8f6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,6 @@ before wiring it into a real workflow. | Doc | Purpose | |---|---| | [everos-demo.md](everos-demo.md) | `everos demo` — local educational TUI to feel the memory lifecycle before configuring keys | -| [use-cases.md](use-cases.md) | Worked examples and integrations showing what persistent memory enables, to study and adapt | ## Reference @@ -51,16 +50,16 @@ specific thing (drain a queue, recover from a stuck row, etc.). |---|---| | [cascade_runbook.md](cascade_runbook.md) | Cascade subsystem ops — drain queue, recover stuck rows | | [github-sync.md](github-sync.md) | Guardrails for refreshing GitHub from internal exports without overwriting GitHub-only workflow files | -| [locomo_benchmark.md](locomo_benchmark.md) | LoCoMo benchmark — run and evaluate | +| [benchmarks/README.md](../benchmarks/README.md) | LoCoMo benchmark — run and evaluate | -## Engineering +## Engineering / Internal -For contributors working on the framework itself — how to build, test, -and ship a change. +For maintainers and contributors working on the framework itself, +not for using it. | Doc | Purpose | |---|---| -| [engineering.md](engineering.md) | Contributor engineering reference — build, test, CI gates, branch & commit conventions | +| [engineering.md](engineering.md) | Engineering & dev-efficiency infrastructure (CI / tooling / Claude Code) | ## See also diff --git a/docs/locomo_benchmark.md b/docs/locomo_benchmark.md deleted file mode 100644 index 1fe5f14cb..000000000 --- a/docs/locomo_benchmark.md +++ /dev/null @@ -1,137 +0,0 @@ -# Running the LoCoMo Benchmark - -This guide walks through reproducing EverOS's LoCoMo retrieval scores -locally using the `hybrid` and `agentic` search methods. - -## Contents - -- [Prerequisites](#prerequisites) -- [1. Prepare the dataset](#1-prepare-the-dataset) -- [2. Start the server](#2-start-the-server) -- [3. Run `hybrid`](#3-run-hybrid) -- [4. Run `agentic`](#4-run-agentic) -- [5. Where the results land](#5-where-the-results-land) -- [Notes](#notes) - ---- - -## Prerequisites - -- Python **3.12**, [uv](https://docs.astral.sh/uv/) -- LLM / embedding credentials configured via `everos.toml` in the memory - root (generated by `everos init`) or `EVEROS_*` env vars: - - `EVEROS_LLM__MODEL`, `EVEROS_LLM__API_KEY`, `EVEROS_LLM__BASE_URL` - - `EVEROS_EMBEDDING__*` - - `EVEROS_RERANK__*` - - The benchmark driver also reads `LLM_API_KEY` / `ANSWER_MODEL` / - `JUDGE_MODEL` for the answer + judge passes. - -Install the project: - -```bash -uv sync -``` - -## 1. Prepare the dataset - -Place the LoCoMo file at `data/locomo10.json` (the dataset is -distributed by the LoCoMo authors, not this repo). Override the path -later with `--data-path` if you keep it elsewhere. - -## 2. Start the server - -```bash -EVEROS_ROOT=~/.everos \ -uv run everos server start --port 8000 -``` - -`EVEROS_ROOT` isolates one benchmark's corpus from another — -change it (or `rm -rf` it) whenever you want a clean run. - -Leave the server running in one terminal; run the benchmark from -another. - -## 3. Run `hybrid` - -Single conversation: - -```bash -bash tests/run_locomo_batch.sh \ - --conv-indices 0 \ - --methods hybrid \ - --base-url http://localhost:8000 \ - --top-k 10 -``` - -All 10 conversations, 2-way parallel: - -```bash -bash tests/run_locomo_batch.sh \ - --conv-indices 0-9 \ - --methods hybrid \ - --base-url http://localhost:8000 \ - --top-k 10 \ - --concurrency 2 -``` - -The wrapper picks up `EVEROS_ROOT` from the environment so the -cascade poll path matches the server's data root. If you set them -differently, pass `--corpus-path` explicitly. - -## 4. Run `agentic` - -Same wrapper, swap `--methods`: - -```bash -bash tests/run_locomo_batch.sh \ - --conv-indices 0-9 \ - --methods agentic \ - --base-url http://localhost:8000 \ - --top-k 10 \ - --concurrency 2 -``` - -You can also benchmark multiple methods in one go — they share the -same ingested corpus: - -```bash -bash tests/run_locomo_batch.sh \ - --conv-indices 0-9 \ - --methods hybrid,agentic \ - --base-url http://localhost:8000 \ - --top-k 10 \ - --concurrency 2 -``` - -## 5. Where the results land - -Default output root is `benchmark_results/run_/`. Override -with `--output-root`: - -``` -/ -├── conv0.json … conv9.json # per-conv summary + per-question details -├── conv0.log … conv9.log # per-conv stdout (only in --concurrency >1 mode) -└── conv0_checkpoints/ … # incremental search/answer/eval JSON -``` - -An aggregate accuracy table prints at the end of the wrapper run. - -## Notes - -- **Smoke mode**: add `--smoke` for a quick sanity check — 2 sessions - × 5 QA with reduced wait times. Not a scored run, just pipeline - verification. Pass it to `test_locomo.py` directly or via the batch - wrapper's `--` passthrough: - - ```bash - uv run python tests/test_locomo.py --smoke --base-url http://localhost:8000 - # or via the wrapper - bash tests/run_locomo_batch.sh --conv-indices 0 --methods hybrid -- --smoke - ``` - -- **Re-running on the same corpus**: add `--skip-add` to skip ingest and - reuse what's already in `~/.everos`. Useful when comparing methods - side by side. -- **Judge variance**: `--judge-runs 3` runs the judge three times per - question and majority-votes; slower but reduces LLM-judge noise. diff --git a/docs/migration-to-1.0.0.md b/docs/migration-to-1.0.0.md index bf9df3513..86faa5961 100644 --- a/docs/migration-to-1.0.0.md +++ b/docs/migration-to-1.0.0.md @@ -59,8 +59,8 @@ For new integrations: ## Benchmark Guidance The current LoCoMo reproduction path is documented in -[locomo_benchmark.md](locomo_benchmark.md). The benchmark driver uses -the 1.0.0 server API: add, flush, search, answer, and evaluate. +[benchmarks/README.md](../benchmarks/README.md). The benchmark driver uses +the server API: add, flush, search, answer, and evaluate. Old HyperMem / pre-1.0.0 evaluation pipeline reports should not be used as 1.0.0 bug reports unless they can be reproduced with the current diff --git a/docs/multimodal.md b/docs/multimodal.md index c0100a708..0dc9e4ce3 100644 --- a/docs/multimodal.md +++ b/docs/multimodal.md @@ -91,19 +91,19 @@ audio / HTML / email parsing is unaffected. ### Configure the multimodal LLM -The parser uses its own LLM section, independent from `[llm]`. The model -must accept OpenAI `image_url` parts. Configure these in `everos.toml` -(under `[multimodal]`) or via env vars: - -```bash -EVEROS_MULTIMODAL__MODEL=google/gemini-3-flash-preview -EVEROS_MULTIMODAL__API_KEY= -EVEROS_MULTIMODAL__BASE_URL=https://openrouter.ai/api/v1 +The parser uses its own LLM section (`[multimodal]` in `everos.toml`), +independent from `[llm]`. The model must accept OpenAI `image_url` +parts. Fill in three fields in `everos.toml`: + +```toml +[multimodal] +model = "google/gemini-3-flash-preview" # must support image_url parts +base_url = "https://openrouter.ai/api/v1" +api_key = "" ``` -The default targets Gemini via OpenRouter so a single key covers both -chat extraction and multimodal parsing. See -[Configuration reference](#configuration-reference) for the full list. +See [Configuration reference](#configuration-reference) for the full +field list. ## Supported modalities @@ -264,17 +264,18 @@ httpx.post( ## Configuration reference -All fields bind from the environment via the parent `Settings` -(`EVEROS_MULTIMODAL__`) or the `[multimodal]` TOML section. +All fields live under `[multimodal]` in `everos.toml`. Each can also +be overridden via `EVEROS_MULTIMODAL__` env vars (useful for +containers and CI). -| Env var | Default | Meaning | +| Field | Default | Meaning | |---|---|---| -| `EVEROS_MULTIMODAL__MODEL` | `google/gemini-3-flash-preview` | Parsing model; must accept `image_url` parts | -| `EVEROS_MULTIMODAL__API_KEY` | — | API key for the multimodal endpoint | -| `EVEROS_MULTIMODAL__BASE_URL` | `None` | OpenAI-compatible base URL | -| `EVEROS_MULTIMODAL__MAX_CONCURRENCY` | `4` | Cap on parallel multimodal calls within one extraction | -| `EVEROS_MULTIMODAL__FILE_URI_MAX_BYTES` | `52428800` (50 MiB) | Max size of a `file://` asset | -| `EVEROS_MULTIMODAL__FILE_URI_ALLOW_DIRS` | `[]` (any) | JSON list of allowlisted base dirs for `file://` URIs | +| `model` | `google/gemini-3-flash-preview` | Parsing model; must accept `image_url` parts | +| `base_url` | `https://openrouter.ai/api/v1` | OpenAI-compatible base URL | +| `api_key` | — (required) | API key for the endpoint above | +| `max_concurrency` | `4` | Cap on parallel multimodal calls within one extraction | +| `file_uri_max_bytes` | `52428800` (50 MiB) | Max size of a `file://` asset | +| `file_uri_allow_dirs` | `[]` (any) | Allowlisted base dirs for `file://` URIs | ## Errors and limits diff --git a/docs/openapi.json b/docs/openapi.json index 1c54825b3..53cc024c0 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "everos", "description": "md-first memory extraction framework", - "version": "1.1.0" + "version": "1.1.1" }, "paths": { "/health": { @@ -2622,7 +2622,7 @@ "enable_llm_rerank": { "type": "boolean", "title": "Enable Llm Rerank", - "description": "Opt-in LLM rerank pass for HYBRID. Applies to agent_case and agent_skill fusion only; the episode hierarchy path has built-in fact eviction and ignores this flag. Ignored by keyword / vector / agentic.", + "description": "Opt-in LLM rerank pass for HYBRID. Applies to agent_case and agent_skill fusion only; the episode hybrid path has built-in fact eviction and ignores this flag. Ignored by keyword / vector / agentic.", "default": false }, "filters": { diff --git a/pyproject.toml b/pyproject.toml index 2edd079fa..d173a810c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "everos" -version = "1.1.0" +version = "1.1.1" description = "EverOS — local-first markdown memory framework for AI agents and user chats; lightweight, dev-friendly, small-team" license = {text = "Apache-2.0"} readme = "README.md" @@ -15,13 +15,15 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Typing :: Typed", ] + dependencies = [ # Core data validation & config "pydantic>=2.7.1", "pydantic-settings>=2.0.0", - "python-dotenv>=1.0.1", # Storage stack (md-first three-piece set) "lancedb>=0.13.0", # Vector + BM25 + scalar filter (Arrow-based) @@ -46,12 +48,12 @@ dependencies = [ "structlog>=24.0.0", "prometheus-client>=0.20.0", - # CLI + # CLI + TUI "typer>=0.12.0", "textual>=8.2.7", # Tokenization (BM25 Chinese support) - "jieba==0.42.1", + "jieba>=0.42.1,<1.0", # OME (Offline Memory Evolution) async scheduler & file I/O "apscheduler>=3.10.4,<4.0", # async strategy scheduler chassis @@ -69,6 +71,12 @@ dependencies = [ [project.optional-dependencies] multimodal = ["everalgo-parser[svg]>=0.2.1"] # [svg] bundles cairosvg → SVG works by default +[project.urls] +Homepage = "https://evermind.ai" +Repository = "https://github.com/EverMind-AI/EverOS" +Changelog = "https://github.com/EverMind-AI/EverOS/blob/main/CHANGELOG.md" +"Bug Tracker" = "https://github.com/EverMind-AI/EverOS/issues" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -90,10 +98,8 @@ packages = ["src/everos"] include = [ "/src", "/tests", - "/data", "/docs", "/pyproject.toml", - "/uv.lock", "/README.md", "/QUICKSTART.md", "/CHANGELOG.md", @@ -121,14 +127,20 @@ target-version = "py312" extend-exclude = ["src_old"] [tool.ruff.lint] -select = ["E", "F", "I", "N", "UP", "B", "SIM", "ASYNC"] +select = ["E", "F", "I", "N", "UP", "B", "SIM", "ASYNC", "RUF"] +ignore = [ + "RUF001", # ambiguous Unicode in strings (intentional × – symbols) + "RUF002", # ambiguous Unicode in docstrings + "RUF003", # ambiguous Unicode in comments + "RUF012", # mutable class attribute default (SQLModel requires this) +] [tool.ruff.lint.per-file-ignores] # LoCoMo benchmark embeds LLM prompts (ANSWER_PROMPT / JUDGE_*_PROMPT) and # verbose argparse help strings as multi-line string literals. Line breaks # would change what the LLM actually sees or noise the --help output, so # E501 is silenced for this file only. -"tests/test_locomo.py" = ["E501"] +"benchmarks/run.py" = ["E501"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -161,6 +173,15 @@ source = ["src/everos"] branch = true omit = ["**/__init__.py"] +[tool.coverage.report] +fail_under = 80 +show_missing = true +exclude_lines = [ + "if TYPE_CHECKING:", + "@abstractmethod", + "pragma: no cover", +] + [tool.importlinter] root_packages = ["everos"] diff --git a/src/everos/component/embedding/factory.py b/src/everos/component/embedding/factory.py index 304ab56ab..4f63493ae 100644 --- a/src/everos/component/embedding/factory.py +++ b/src/everos/component/embedding/factory.py @@ -36,8 +36,7 @@ def build_embedding_provider( "Embedding model is not configured " "(set EVEROS_EMBEDDING__MODEL or [embedding] model in user toml)" ) - api_key = settings.api_key.get_secret_value() if settings.api_key else "" - if not api_key: + if not settings.api_key or not settings.api_key.get_secret_value(): raise ValueError( "Embedding api_key is not configured (set EVEROS_EMBEDDING__API_KEY)" ) @@ -47,7 +46,7 @@ def build_embedding_provider( ) return OpenAIEmbeddingProvider( model=settings.model, - api_key=api_key, + api_key=settings.api_key.get_secret_value(), base_url=settings.base_url, dim=dim, timeout=settings.timeout_seconds, diff --git a/src/everos/component/llm/factory.py b/src/everos/component/llm/factory.py index a7d143c0e..5dff064aa 100644 --- a/src/everos/component/llm/factory.py +++ b/src/everos/component/llm/factory.py @@ -28,8 +28,7 @@ def build_llm_provider(settings: LLMSettings) -> LLMClient: Raises: ValueError: If ``api_key`` or ``base_url`` is unset. """ - api_key = settings.api_key.get_secret_value() if settings.api_key else "" - if not api_key: + if not settings.api_key or not settings.api_key.get_secret_value(): raise ValueError( "LLM api_key is not configured " "(set EVEROS_LLM__API_KEY or [llm] api_key in user toml)" @@ -41,6 +40,6 @@ def build_llm_provider(settings: LLMSettings) -> LLMClient: ) return OpenAIProvider( model=settings.model, - api_key=api_key, + api_key=settings.api_key.get_secret_value(), base_url=settings.base_url, ) diff --git a/src/everos/component/rerank/__init__.py b/src/everos/component/rerank/__init__.py index b36f30699..1ce9ca7c8 100644 --- a/src/everos/component/rerank/__init__.py +++ b/src/everos/component/rerank/__init__.py @@ -6,9 +6,10 @@ - :class:`RerankResult` / :class:`RerankServiceError` — value type + error. - :class:`RerankError` — backward-compat alias for :class:`RerankServiceError`. - :class:`DeepInfraRerankProvider` — DeepInfra inference-API rerank. -- :class:`DashScopeRerankProvider` — Aliyun Bailian / DashScope rerank. - :class:`VllmRerankProvider` — OpenAI-compat ``/v1/rerank`` (vLLM, self-hosted, other compatible servers). +- :class:`DashScopeRerankProvider` — DashScope (Aliyun Bailian) + ``gte-rerank-v2`` native text-rerank endpoint. - :func:`build_rerank_provider` — settings-driven factory that picks the concrete provider via ``settings.rerank.provider``. diff --git a/src/everos/component/rerank/factory.py b/src/everos/component/rerank/factory.py index 1161a35e7..1cf4c1a0f 100644 --- a/src/everos/component/rerank/factory.py +++ b/src/everos/component/rerank/factory.py @@ -3,10 +3,9 @@ The ``provider`` field on :class:`RerankSettings` selects which concrete implementation to build: - - ``"deepinfra"`` → :class:`DeepInfraRerankProvider` - - ``"vllm"`` → :class:`VllmRerankProvider` - - ``"dashscope"`` → :class:`DashScopeRerankProvider` - (Aliyun Bailian ``gte-rerank-v2``) + - ``"deepinfra"`` → :class:`DeepInfraRerankProvider` + - ``"vllm"`` → :class:`VllmRerankProvider` + - ``"dashscope"`` → :class:`DashScopeRerankProvider` Adding a new provider = one match arm here + one new file under :mod:`everos.component.rerank`. @@ -14,42 +13,13 @@ from __future__ import annotations -from urllib.parse import urlparse - from everos.config import RerankSettings -from everos.core.observability.logging import get_logger from .dashscope_provider import DashScopeRerankProvider from .deepinfra_provider import DeepInfraRerankProvider from .protocol import RerankProvider from .vllm_provider import VllmRerankProvider -logger = get_logger(__name__) - -# host substring -> provider. Ordered most-specific first; matched against -# the ``base_url`` host so a Bailian / DeepInfra URL routes to the right -# request-shape without the operator also having to set ``provider``. -_PROVIDER_HOST_HINTS: tuple[tuple[str, str], ...] = ( - ("dashscope.aliyuncs.com", "dashscope"), - ("deepinfra.com", "deepinfra"), -) - -# Fallback when the host matches no hint and ``provider`` is unset. Keeps -# the historical default so existing configs without ``provider`` are -# unaffected. -_DEFAULT_PROVIDER = "deepinfra" - - -def _infer_provider(base_url: str) -> str | None: - """Infer the rerank provider from the ``base_url`` host, or ``None``.""" - host = (urlparse(base_url).hostname or "").lower() - if not host: - return None - for needle, provider in _PROVIDER_HOST_HINTS: - if host == needle or host.endswith(f".{needle}"): - return provider - return None - def build_rerank_provider(settings: RerankSettings) -> RerankProvider: """Build a rerank provider from settings. @@ -64,14 +34,8 @@ def build_rerank_provider(settings: RerankSettings) -> RerankProvider: Raises: ValueError: If ``model`` or ``base_url`` is unset, or if ``provider`` does not match a known implementation. - ``api_key`` is required for ``deepinfra`` and ``dashscope``; - optional (empty string) for ``vllm`` self-hosted endpoints. - ``dashscope`` currently supports ``gte-rerank-v2`` only. - - Notes: - When ``settings.provider`` is ``None`` the provider is inferred - from the ``base_url`` host (see :data:`_PROVIDER_HOST_HINTS`), - falling back to ``"deepinfra"`` for unrecognized hosts. + ``api_key`` is required for ``deepinfra``; optional (empty + string) for ``vllm`` self-hosted endpoints. """ if not settings.model: raise ValueError( @@ -84,18 +48,7 @@ def build_rerank_provider(settings: RerankSettings) -> RerankProvider: ) api_key = settings.api_key.get_secret_value() if settings.api_key else "" - provider = settings.provider - if provider is None: - inferred = _infer_provider(settings.base_url) - provider = inferred or _DEFAULT_PROVIDER - logger.info( - "rerank_provider_inferred", - provider=provider, - inferred=inferred is not None, - base_url=settings.base_url, - ) - - if provider == "deepinfra": + if settings.provider == "deepinfra": if not api_key: raise ValueError( "DeepInfra rerank api_key is not configured " @@ -110,7 +63,7 @@ def build_rerank_provider(settings: RerankSettings) -> RerankProvider: batch_size=settings.batch_size, max_concurrent=settings.max_concurrent, ) - if provider == "vllm": + if settings.provider == "vllm": return VllmRerankProvider( model=settings.model, api_key=api_key, @@ -120,7 +73,7 @@ def build_rerank_provider(settings: RerankSettings) -> RerankProvider: batch_size=settings.batch_size, max_concurrent=settings.max_concurrent, ) - if provider == "dashscope": + if settings.provider == "dashscope": if not api_key: raise ValueError( "DashScope rerank api_key is not configured " @@ -135,4 +88,4 @@ def build_rerank_provider(settings: RerankSettings) -> RerankProvider: batch_size=settings.batch_size, max_concurrent=settings.max_concurrent, ) - raise ValueError(f"unknown rerank provider: {provider!r}") + raise ValueError(f"unknown rerank provider: {settings.provider!r}") diff --git a/src/everos/config/default.toml b/src/everos/config/default.toml index ad9bf7b58..68fab81f1 100644 --- a/src/everos/config/default.toml +++ b/src/everos/config/default.toml @@ -54,18 +54,18 @@ cache_size_kb = 2048 # Provider-agnostic OpenAI-protocol client config. Override via env: # EVEROS_LLM__MODEL, EVEROS_LLM__API_KEY, EVEROS_LLM__BASE_URL # Or set the field directly in this file (/everos.toml). -model = "gpt-4.1-mini" -# api_key = "" -# base_url = "" +model = "openai/gpt-4.1-mini" +api_key = "" +base_url = "https://openrouter.ai/api/v1" [multimodal] # Independent LLM for multimodal parsing (everalgo-parser); must accept # image / pdf / audio image_url parts. Override via env: # EVEROS_MULTIMODAL__MODEL, EVEROS_MULTIMODAL__API_KEY, EVEROS_MULTIMODAL__BASE_URL model = "google/gemini-3-flash-preview" +api_key = "" +base_url = "https://openrouter.ai/api/v1" max_concurrency = 4 -# api_key = "" -# base_url = "" # file:// content-item support (read locally by EverOS, not everalgo). # file_uri_allow_dirs: empty = allow any readable file (local-first default); # list base dirs to confine reads when the API is exposed. @@ -75,11 +75,10 @@ max_concurrency = 4 [embedding] # OpenAI-compatible embedding endpoint. Override via env: # EVEROS_EMBEDDING__MODEL, EVEROS_EMBEDDING__API_KEY, EVEROS_EMBEDDING__BASE_URL -# model / api_key / base_url have no shipped defaults — must be set -# (env or user toml) before the embedding capability is used. -# model = "Qwen/Qwen3-Embedding-4B" -# api_key = "" -# base_url = "https://api.example.com/v1" +# model and base_url ship with recommended defaults; api_key must be set. +model = "Qwen/Qwen3-Embedding-4B" +api_key = "" +base_url = "https://api.deepinfra.com/v1/openai" timeout_seconds = 30.0 max_retries = 3 batch_size = 10 @@ -92,17 +91,10 @@ max_concurrent = 5 # `provider` picks the request-shape: # - "deepinfra" -> POST {base_url}/{model} (DeepInfra inference API) # - "vllm" -> POST {base_url}/rerank (OpenAI-compat rerank endpoint) -# - "dashscope" -> POST {base_url}/api/v1/services/rerank/text-rerank/text-rerank -# (Aliyun Bailian native text-rerank; currently -# "gte-rerank-v2", base_url "https://dashscope.aliyuncs.com") -# Left unset, the factory infers the provider from the base_url host -# (dashscope.aliyuncs.com -> dashscope, *.deepinfra.com -> deepinfra) and -# falls back to "deepinfra" for unknown hosts. Set it explicitly to -# override — required for self-hosted "vllm" on an arbitrary host. -# provider = "deepinfra" -# model = "Qwen/Qwen3-Reranker-4B" -# api_key = "" -# base_url = "https://api.deepinfra.com/v1/inference" +provider = "deepinfra" +model = "Qwen/Qwen3-Reranker-4B" +api_key = "" +base_url = "https://api.deepinfra.com/v1/inference" timeout_seconds = 30.0 max_retries = 3 batch_size = 10 @@ -113,16 +105,6 @@ max_concurrent = 5 hard_token_limit = 65536 hard_msg_limit = 500 -[search] -# Vector retrieval strategy when SearchMethod.VECTOR is selected. -# "maxsim_atomic" (default): ANN over atomic_fact.vector (pool=top_k*20), -# max-pool the per-fact cosine by parent memcell, then reverse-resolve -# to episode rows. MaxSim over atomic facts; +0.6pp over the legacy -# episode-vector path on LoCoMo, at the cost of one extra LanceDB scan. -# "episode": single-vector ANN over episode.vector (legacy path). -# Override via EVEROS_SEARCH__VECTOR_STRATEGY. -vector_strategy = "maxsim_atomic" - [memorize] # Conversation mode. Selects the boundary detector and which pipelines run: # "chat" -> BoundaryDetector + user_memory only @@ -131,14 +113,6 @@ vector_strategy = "maxsim_atomic" # requires a restart. Override via EVEROS_MEMORIZE__MODE. mode = "agent" -# Maximum wall-clock for one memorize() invocation while holding the -# per-session lock. On timeout the outer asyncio.timeout cancels the call -# and the lock auto-releases so subsequent concurrent /add on the same -# session aren't deadlocked. Covers boundary LLM + memcell writes + -# synchronous portion of pipeline dispatch. -# Override via EVEROS_MEMORIZE__SESSION_LOCK_TIMEOUT_SECONDS. -session_lock_timeout_seconds = 360.0 - [knowledge] # Max bytes for an uploaded knowledge document (default 50 MiB). Oversized # uploads are rejected with HTTP 422 before parsing/extraction. Note: the @@ -155,6 +129,14 @@ lambda = 0.1 mass_top_m = 50 top_k_cap = 100 +# Maximum wall-clock for one memorize() invocation while holding the +# per-session lock. On timeout the outer asyncio.timeout cancels the call +# and the lock auto-releases so subsequent concurrent /add on the same +# session aren't deadlocked. Covers boundary LLM + memcell writes + +# synchronous portion of pipeline dispatch. +# Override via EVEROS_MEMORIZE__SESSION_LOCK_TIMEOUT_SECONDS. +session_lock_timeout_seconds = 360.0 + [clustering] # Geometry-clustering: cosine similarity threshold and time window. # Episodes older than ``time_window_days`` from the newest cluster @@ -162,3 +144,4 @@ top_k_cap = 100 # Override via EVEROS_CLUSTERING__THRESHOLD, EVEROS_CLUSTERING__TIME_WINDOW_DAYS. threshold = 0.65 time_window_days = 7.0 + diff --git a/src/everos/config/default_ome.toml b/src/everos/config/default_ome.toml index 864d7249c..e326346a6 100644 --- a/src/everos/config/default_ome.toml +++ b/src/everos/config/default_ome.toml @@ -26,36 +26,30 @@ # ── User-memory pipeline ──────────────────────────────────────────────── -# Atomic fact extraction (runs per memcell). Disable to skip fact mining. -# [strategies.extract_atomic_facts] -# enabled = true +# Atomic fact extraction runs per memcell. Always enabled — search +# depends on atomic facts for MaxSim retrieval. -# Foresight extraction (runs per memcell). Heavy LLM call — common to -# disable in evaluation / benchmark runs. +# Foresight extraction (runs per memcell). Heavy LLM call — set +# enabled = false to skip in evaluation / benchmark runs. # [strategies.extract_foresight] -# enabled = false +# enabled = true -# Profile clustering trigger (counter-gated; fires once N user memcells -# accumulate). Lower the threshold to cluster more aggressively. +# Profile clustering trigger (fires on each EpisodeExtracted event). # [strategies.trigger_profile_clustering] # enabled = true -# [strategies.trigger_profile_clustering.gate] -# threshold = 5 -# User-profile extraction (runs after clustering trigger fires). Common -# to disable in evaluation runs where ground-truth profiles aren't measured. +# User-profile extraction (runs after clustering trigger fires). Set +# enabled = false to skip in evaluation runs. # [strategies.extract_user_profile] -# enabled = false +# enabled = true # ── Reflection ────────────────────────────────────────────────────────── -# Offline memory consolidation. Disabled by default — uncomment to enable. -# Once enabled, runs on the default schedule: weekly, Monday 02:00. -# Run Reflection sparingly — at most once a week. Triggering it too -# often degrades quality, since each run is a lossy LLM re-merge. -# The cron expression can be overridden without restarting the server. +# Offline memory consolidation. Disabled by default. To enable, +# uncomment and set enabled = true. Runs weekly Monday 02:00. +# Run sparingly — at most once a week. Each run is a lossy LLM re-merge. # [strategies.reflect_episodes] -# enabled = true +# enabled = false # cron = "0 2 * * 1" # ── Agent-memory pipeline ─────────────────────────────────────────────── @@ -64,12 +58,9 @@ # [strategies.extract_agent_case] # enabled = true -# Skill clustering trigger (counter-gated; fires once N agent cases -# accumulate per agent). +# Skill clustering trigger (fires on each AgentCaseExtracted event). # [strategies.trigger_skill_clustering] # enabled = true -# [strategies.trigger_skill_clustering.gate] -# threshold = 5 # Agent skill extraction (runs after skill clustering trigger fires). # [strategies.extract_agent_skill] diff --git a/src/everos/config/settings.py b/src/everos/config/settings.py index bdde544a9..5ae2c8da8 100644 --- a/src/everos/config/settings.py +++ b/src/everos/config/settings.py @@ -188,7 +188,7 @@ class EmbeddingSettings(BaseModel): timeout_seconds: float = Field(default=30.0, gt=0) max_retries: int = Field(default=3, ge=0) batch_size: int = Field(default=10, ge=1) - max_concurrent: int = Field(default=5, ge=1) + max_concurrent: int = Field(default=50, ge=1) class RerankSettings(BaseModel): @@ -197,16 +197,8 @@ class RerankSettings(BaseModel): Unlike LLM / embedding (single OpenAI-compatible shape), rerank API schemas differ between providers — DeepInfra uses ``POST {base_url}/ {model}`` with a custom body, vLLM uses ``POST {base_url}/rerank`` - with ``{model, query, documents}``, DashScope (Aliyun Bailian) - ``gte-rerank-v2`` uses ``POST {base_url}/api/v1/services/rerank/ - text-rerank/text-rerank`` with a nested ``{model, input, parameters}`` - body. ``provider`` picks which client implementation the factory builds. - - ``provider`` defaults to ``None`` — the factory then infers it from - the ``base_url`` host (e.g. ``dashscope.aliyuncs.com`` → DashScope, - ``*.deepinfra.com`` → DeepInfra), falling back to ``"deepinfra"`` when - the host is unrecognized. Set ``provider`` explicitly to override the - inference (required for self-hosted ``vllm`` on an arbitrary host). + with ``{model, query, documents}``. ``provider`` picks which client + implementation the factory builds. Env binding: EVEROS_RERANK__PROVIDER @@ -219,14 +211,14 @@ class RerankSettings(BaseModel): EVEROS_RERANK__MAX_CONCURRENT """ - provider: Literal["deepinfra", "vllm", "dashscope"] | None = None + provider: Literal["deepinfra", "vllm", "dashscope"] = "deepinfra" model: str | None = None api_key: SecretStr | None = None base_url: str | None = None timeout_seconds: float = Field(default=30.0, gt=0) max_retries: int = Field(default=3, ge=0) batch_size: int = Field(default=10, ge=1) - max_concurrent: int = Field(default=5, ge=1) + max_concurrent: int = Field(default=50, ge=1) class BoundaryDetectionSettings(BaseModel): @@ -276,28 +268,6 @@ class ClusteringSettings(BaseModel): time_window_days: float = Field(default=7.0, gt=0) -class SearchSettings(BaseModel): - """Search-pipeline policy knobs. - - ``vector_strategy`` selects the read path taken by - ``SearchMethod.VECTOR``: - - - ``"maxsim_atomic"`` (default) — ANN over ``atomic_fact.vector`` - (recall pool ``top_k * 20``, capped at 2000), max-pool the per-fact - cosine by parent memcell, then reverse-resolve the top memcells back - to episode rows. MaxSim over atomic facts; trades one extra LanceDB - scan for finer-grained semantic match on long episodes. - - ``"episode"`` — single-vector ANN over ``episode.vector`` (one vector - per episode = the embedded Content section). The legacy path; kept - so deployments can opt out via env. - - Env binding: - EVEROS_SEARCH__VECTOR_STRATEGY={episode,maxsim_atomic} - """ - - vector_strategy: Literal["episode", "maxsim_atomic"] = "maxsim_atomic" - - class LanceDBSettings(BaseModel): """LanceDB tunables. @@ -393,7 +363,6 @@ class Settings(BaseSettings): boundary_detection: BoundaryDetectionSettings = BoundaryDetectionSettings() memorize: MemorizeSettings = MemorizeSettings() clustering: ClusteringSettings = ClusteringSettings() - search: SearchSettings = SearchSettings() multimodal: MultimodalSettings = MultimodalSettings() knowledge: KnowledgeSettings = KnowledgeSettings() diff --git a/src/everos/core/middleware/profile.py b/src/everos/core/middleware/profile.py index 370c0fab0..546d3d00d 100644 --- a/src/everos/core/middleware/profile.py +++ b/src/everos/core/middleware/profile.py @@ -62,8 +62,12 @@ async def dispatch( profiler.start() logger.info("profile_started", method=request.method, path=request.url.path) try: - await call_next(request) + response = await call_next(request) except Exception: - logger.exception("profile_request_failed") + profiler.stop() + raise profiler.stop() - return HTMLResponse(content=profiler.output_html(), status_code=200) + return HTMLResponse( + content=profiler.output_html(), + status_code=response.status_code, + ) diff --git a/src/everos/core/persistence/__init__.py b/src/everos/core/persistence/__init__.py index 85e60d246..cb114b84e 100644 --- a/src/everos/core/persistence/__init__.py +++ b/src/everos/core/persistence/__init__.py @@ -82,9 +82,9 @@ "ParsedMarkdown", "Relationship", "RepoBase", + "SQLModel", "SkillPathMixin", "StructuredEntry", - "SQLModel", "UserScopedFrontmatter", "Vector", "app_dir_name", @@ -94,11 +94,11 @@ "dump_frontmatter", "find_entry", "memory_root_lock", - "project_dir_name", - "project_id_from_dir", "open_lancedb_connection", "parse_frontmatter", "parse_structured_entry", + "project_dir_name", + "project_id_from_dir", "render_structured_entry", "session_scope", "split_entries", diff --git a/src/everos/core/persistence/lancedb/repository.py b/src/everos/core/persistence/lancedb/repository.py index 0c3a9de12..9c4d88fea 100644 --- a/src/everos/core/persistence/lancedb/repository.py +++ b/src/everos/core/persistence/lancedb/repository.py @@ -176,25 +176,39 @@ async def upsert( async def optimize(self, *, cleanup_older_than: dt.timedelta | None = None) -> None: """Compact fragments + merge new data into the FTS / vector indexes. - LanceDB's ``merge_insert`` writes new data into a fresh fragment. - The FTS (BM25) index built by :meth:`ensure_fts_indexes` only - covers fragments visible at index-build time, so rows written - after the initial build can become **invisible to BM25 queries** - until ``optimize()`` runs and merges those fragments into the - index segment that the query engine reads. - - Symptom this guards against (verified on LoCoMo conv0): after - steady-state cascade ingest, ``nearest_to_text("any_common_word")`` - returns 0 hits even though the column literally contains the - token in 100% of rows — the new fragments simply hadn't been - indexed. + ``optimize()`` is a **performance + storage-hygiene** operation, + **not** a correctness/visibility one. LanceDB's ``merge_insert`` + writes new data into a fresh fragment that the FTS / vector + indexes don't cover yet; queries stay correct regardless because + LanceDB transparently brute-force flat-scans that unindexed tail + and unions it with the indexed hits. Verified on lancedb 0.30.2: + after ``create_index`` + ``merge_insert`` (no ``optimize()``), a + ``nearest_to_text`` for a token present only in the new rows + returns those rows immediately. + + (Older lancedb — at/below the ``>=0.13.0`` floor this repo once + pinned — did **not** flat-scan the FTS tail, so post-build rows + were genuinely invisible to BM25 until ``optimize()``; that is + the behaviour the historical LoCoMo-conv0 note described. The + flat-scan fallback closed that gap, so optimize is now purely + about keeping that tail small.) + + What ``optimize()`` actually buys on the current stack: + + - **Query speed** — the unindexed tail is flat-scanned on every + query; merging it into the index keeps that scan bounded as + ingest accumulates. + - **Storage hygiene** — with ``cleanup_older_than`` it prunes + replaced fragments / stale manifests / dead index files, + bounding the on-disk file count (and FD usage at scan time). Cascade triggers this through a per-kind throttle + trailing edge scheduler (``CascadeWorker._schedule_optimize``): at most - one run per ~1s window per kind, decoupled from the drain + one run per throttle window per kind, decoupled from the drain loop, with a 60s heartbeat sweep as a safety net. Cost is O(N) data-rewrite per optimized fragment; the throttle is how - we cap it under sustained write pressure. + we cap it under sustained write pressure. Because visibility no + longer depends on it, the throttle window can be generous. Args: cleanup_older_than: When set, also prune (physically delete) diff --git a/src/everos/core/persistence/markdown/entries.py b/src/everos/core/persistence/markdown/entries.py index 6dabb9873..31f83c819 100644 --- a/src/everos/core/persistence/markdown/entries.py +++ b/src/everos/core/persistence/markdown/entries.py @@ -85,7 +85,7 @@ def format(self) -> str: f"{self.prefix}_{self.date.strftime(_DATE_FMT)}_{self.seq:0{_SEQ_DIGITS}d}" ) - def __str__(self) -> str: # noqa: D401 + def __str__(self) -> str: return self.format() @classmethod diff --git a/src/everos/entrypoints/api/app.py b/src/everos/entrypoints/api/app.py index 337fdc563..1583671b2 100644 --- a/src/everos/entrypoints/api/app.py +++ b/src/everos/entrypoints/api/app.py @@ -11,7 +11,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from everos import __version__ from everos.core.lifespan import ( LifespanProvider, MetricsLifespanProvider, @@ -88,6 +87,8 @@ def create_app( OmeLifespanProvider(), ] + from everos import __version__ + app = FastAPI( title="everos", version=__version__, diff --git a/src/everos/entrypoints/api/lifespans/ome.py b/src/everos/entrypoints/api/lifespans/ome.py index d597edf45..4df6cad5a 100644 --- a/src/everos/entrypoints/api/lifespans/ome.py +++ b/src/everos/entrypoints/api/lifespans/ome.py @@ -27,13 +27,13 @@ def __init__(self, order: int = 50) -> None: async def startup(self, app: FastAPI) -> Any: svc = importlib.import_module("everos.service.memorize") - engine = svc._get_engine() # noqa: SLF001 — service-internal accessor + engine = svc._get_engine() await engine.start() logger.info("ome_engine_started") return engine async def shutdown(self, app: FastAPI) -> None: svc = importlib.import_module("everos.service.memorize") - engine = svc._get_engine() # noqa: SLF001 + engine = svc._get_engine() await engine.stop() logger.info("ome_engine_stopped") diff --git a/src/everos/entrypoints/api/routes/ome.py b/src/everos/entrypoints/api/routes/ome.py index baf3de608..72b4cd9a5 100644 --- a/src/everos/entrypoints/api/routes/ome.py +++ b/src/everos/entrypoints/api/routes/ome.py @@ -32,7 +32,7 @@ class TriggerResponse(BaseModel): async def trigger(req: TriggerRequest) -> TriggerResponse: """Manually trigger a registered OME strategy and wait for completion.""" # Deferred: avoid importing heavy OME engine at module level. - from everos.service.memorize import _get_engine # noqa: SLF001 + from everos.service.memorize import _get_engine engine = _get_engine() try: diff --git a/src/everos/infra/ome/__init__.py b/src/everos/infra/ome/__init__.py index 9602520b1..5ad962f99 100644 --- a/src/everos/infra/ome/__init__.py +++ b/src/everos/infra/ome/__init__.py @@ -49,9 +49,9 @@ "IdleTick", "Immediate", "ManualTick", - "OfflineEngine", "OMEConfig", "OMEError", + "OfflineEngine", "RunRecord", "RunStatus", "StartupValidationError", diff --git a/src/everos/infra/ome/_background/config_reloader.py b/src/everos/infra/ome/_background/config_reloader.py index ca6a65bfb..777f0bfe7 100644 --- a/src/everos/infra/ome/_background/config_reloader.py +++ b/src/everos/infra/ome/_background/config_reloader.py @@ -153,7 +153,7 @@ def _apply_one_strategy( if _needs_aps_reschedule(meta.trigger, new_trigger): _maybe_reschedule_aps(engine, name, new_trigger) registry.replace(name, new_meta) - except Exception as e: # noqa: BLE001 + except Exception as e: # User-fixable config error (typo / type mismatch / APS runtime # failure) — log + skip this strategy's atomic group, never the loop. logger.warning( @@ -228,12 +228,12 @@ async def _loop(self) -> None: """Initial load + per-FS-change reload; survives single-iteration failures.""" try: await self._load_once() - except Exception: # noqa: BLE001 + except Exception: logger.exception("config_reload_iteration_failed") async for _changes in awatch(self._path, debounce=self._debounce_ms): try: await self._load_once() - except Exception: # noqa: BLE001 + except Exception: logger.exception("config_reload_iteration_failed") async def _load_once(self) -> None: diff --git a/src/everos/infra/ome/_background/crash_recovery.py b/src/everos/infra/ome/_background/crash_recovery.py index 15a3ec572..96381463c 100644 --- a/src/everos/infra/ome/_background/crash_recovery.py +++ b/src/everos/infra/ome/_background/crash_recovery.py @@ -70,7 +70,7 @@ async def scan_and_resume( old_run_id=rec.run_id, new_run_id=new_run_id, ) - except Exception: # noqa: BLE001 + except Exception: logger.exception( "crash_recovery_resume_failed", strategy_name=rec.strategy_name, diff --git a/src/everos/infra/ome/_background/idle_scanner.py b/src/everos/infra/ome/_background/idle_scanner.py index df2cd95d1..b7985cb1d 100644 --- a/src/everos/infra/ome/_background/idle_scanner.py +++ b/src/everos/infra/ome/_background/idle_scanner.py @@ -52,7 +52,7 @@ async def scan_once(self, *, now: datetime | None = None) -> None: idle_seconds=self._trigger.idle_seconds, ) ) - except Exception: # noqa: BLE001 + except Exception: logger.exception( "idle_emit_failed", strategy_name=self._name, diff --git a/src/everos/infra/ome/_dispatch/dispatcher.py b/src/everos/infra/ome/_dispatch/dispatcher.py index 19cf6110d..525375fbf 100644 --- a/src/everos/infra/ome/_dispatch/dispatcher.py +++ b/src/everos/infra/ome/_dispatch/dispatcher.py @@ -159,7 +159,7 @@ def _safe_applies(meta: StrategyMeta, event: BaseEvent) -> bool: """ try: return _applies(meta.applies_to, event) - except Exception: # noqa: BLE001 + except Exception: logger.exception( "applies_to_callable_raised", strategy_name=meta.name, diff --git a/src/everos/infra/ome/_dispatch/runner.py b/src/everos/infra/ome/_dispatch/runner.py index 6b8761988..58b4d8c80 100644 --- a/src/everos/infra/ome/_dispatch/runner.py +++ b/src/everos/infra/ome/_dispatch/runner.py @@ -191,7 +191,7 @@ async def _run_one_attempt( except StrategyContractError as e: await self._terminate_dead_letter(current_run_id, _format_error(e)) return True - except Exception as e: # noqa: BLE001 + except Exception as e: err = _format_error(e) if attempt < max_retries_snapshot: await self._rec.mark_failed( @@ -237,7 +237,7 @@ async def _record_start( max_retries_snapshot=max_retries_snapshot, event_id=event_id, ) - except Exception: # noqa: BLE001 + except Exception: logger.exception( "mark_running_failed", run_id=run_id, @@ -264,7 +264,7 @@ async def _fire_dead_letter_callback(self, run_id: str) -> None: return try: self._on_dead_letter(rec) - except Exception: # noqa: BLE001 + except Exception: logger.exception("on_dead_letter_failed") diff --git a/src/everos/infra/ome/testing/harness.py b/src/everos/infra/ome/testing/harness.py index 32e61d055..6cde3d987 100644 --- a/src/everos/infra/ome/testing/harness.py +++ b/src/everos/infra/ome/testing/harness.py @@ -54,7 +54,7 @@ async def __aexit__(self, *exc: Any) -> None: try: await self._engine.stop() finally: - shutil.rmtree(self._tmpdir, ignore_errors=True) # noqa: SLF001 + shutil.rmtree(self._tmpdir, ignore_errors=True) def register(self, strategy: Strategy) -> None: """Register a :class:`Strategy` returned by ``@offline_strategy``. @@ -96,7 +96,7 @@ async def drain(self, *, timeout: float = 30.0) -> None: # noqa: ASYNC109 if not await self._engine.wait_idle(timeout=timeout): raise TimeoutError( f"drain: engine still has " - f"{self._engine._active_runs} in-flight runs after {timeout}s" # noqa: SLF001 + f"{self._engine._active_runs} in-flight runs after {timeout}s" ) async def list_runs( @@ -113,7 +113,7 @@ async def list_runs( Returns: A list of RunRecord objects. """ - return await self._engine._run_record_store.list_runs( # noqa: SLF001 + return await self._engine._run_record_store.list_runs( strategy_name=strategy_name, status=status, ) diff --git a/src/everos/infra/persistence/lancedb/__init__.py b/src/everos/infra/persistence/lancedb/__init__.py index 01fe6e644..832bd3471 100644 --- a/src/everos/infra/persistence/lancedb/__init__.py +++ b/src/everos/infra/persistence/lancedb/__init__.py @@ -26,7 +26,7 @@ # Importing ``tables`` registers every business :class:`BaseLanceTable` # schema so callers can rely on the package alone to surface every schema. -from . import tables as tables # noqa: F401 +from . import tables as tables from .lancedb_manager import dispose_connection as dispose_connection from .lancedb_manager import get_connection as get_connection from .lancedb_manager import get_table as get_table diff --git a/src/everos/infra/persistence/lancedb/tables/episode.py b/src/everos/infra/persistence/lancedb/tables/episode.py index d4e2f4e30..90de9a2b4 100644 --- a/src/everos/infra/persistence/lancedb/tables/episode.py +++ b/src/everos/infra/persistence/lancedb/tables/episode.py @@ -81,3 +81,4 @@ class Episode(BaseLanceTable): supersedes this row. ``NULL`` means the row is still active.""" vector: Vector(_DIM) # type: ignore[valid-type] + subject_vector: Vector(_DIM) | None = None # type: ignore[valid-type] diff --git a/src/everos/infra/persistence/markdown/writers/base.py b/src/everos/infra/persistence/markdown/writers/base.py index dbdc5ca5c..473f31f30 100644 --- a/src/everos/infra/persistence/markdown/writers/base.py +++ b/src/everos/infra/persistence/markdown/writers/base.py @@ -175,7 +175,7 @@ async def append_entries( frontmatter_updates = self._frontmatter_updates( scope_id, bucket, next_count=base_count + len(items) ) - await self._writer._append_entries_unlocked( # noqa: SLF001 + await self._writer._append_entries_unlocked( path, rendered, frontmatter_updates=frontmatter_updates, @@ -227,7 +227,7 @@ async def append( scope_id, bucket, next_count=count + 1 ) - await self._writer._append_entries_unlocked( # noqa: SLF001 + await self._writer._append_entries_unlocked( path, [(entry_body, eid)], frontmatter_updates=frontmatter_updates, diff --git a/src/everos/infra/persistence/sqlite/__init__.py b/src/everos/infra/persistence/sqlite/__init__.py index d7896a26b..bf243fc5f 100644 --- a/src/everos/infra/persistence/sqlite/__init__.py +++ b/src/everos/infra/persistence/sqlite/__init__.py @@ -30,7 +30,7 @@ # Importing ``tables`` registers every business SQLModel in # ``SQLModel.metadata`` so ``SqliteLifespanProvider.startup`` can # ``create_all`` without callers having to import each model module. -from . import tables as tables # noqa: F401 +from . import tables as tables from .repos import DocumentListPage as DocumentListPage from .repos import DocumentUpsertPayload as DocumentUpsertPayload from .repos import QueueSummary as QueueSummary diff --git a/src/everos/infra/persistence/sqlite/repos/knowledge.py b/src/everos/infra/persistence/sqlite/repos/knowledge.py index 26cfc7d6d..a6ebf505d 100644 --- a/src/everos/infra/persistence/sqlite/repos/knowledge.py +++ b/src/everos/infra/persistence/sqlite/repos/knowledge.py @@ -9,7 +9,7 @@ import dataclasses from sqlalchemy import asc, delete, desc, func, select -from sqlalchemy.dialects.sqlite import insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from everos.component.utils.datetime import get_utc_now @@ -69,20 +69,6 @@ class TopicUpsertPayload: md_path: str -def _apply_document_fields( - row: KnowledgeDocumentRow, payload: DocumentUpsertPayload -) -> None: - """Copy payload fields onto an existing SQLAlchemy row.""" - row.app_id = payload.app_id - row.project_id = payload.project_id - row.category_id = payload.category_id - row.title = payload.title - row.summary = payload.summary - row.source_name = payload.source_name - row.source_type = payload.source_type - row.md_path = payload.md_path - - class _KnowledgeDocumentRepo(RepoBase[KnowledgeDocumentRow]): """SQLite repository for ``knowledge_documents`` table.""" @@ -200,61 +186,36 @@ async def count_by_category(self, app_id: str, project_id: str) -> dict[str, int async def upsert_from_handler(self, payload: DocumentUpsertPayload) -> None: """Insert or update a document row from the cascade handler. - Use SQLite's atomic ``ON CONFLICT`` form rather than a - load-mutate-commit cycle. Category moves can produce concurrent - cascade events for the old and new ``index.md`` paths; an ORM row - loaded before a delete can otherwise raise ``StaleDataError`` on - flush. + Uses SQLite ``INSERT ... ON CONFLICT DO UPDATE`` to avoid the + ``StaleDataError`` that occurs when a concurrent cascade handler + deletes the row between a ``get`` and ``commit``. """ + now = get_utc_now() + values = { + **dataclasses.asdict(payload), + "created_at": now, + "updated_at": now, + } + stmt = sqlite_insert(KnowledgeDocumentRow).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=["doc_id"], + set_={ + "app_id": stmt.excluded.app_id, + "project_id": stmt.excluded.project_id, + "category_id": stmt.excluded.category_id, + "title": stmt.excluded.title, + "summary": stmt.excluded.summary, + "source_name": stmt.excluded.source_name, + "source_type": stmt.excluded.source_type, + "md_path": stmt.excluded.md_path, + "updated_at": stmt.excluded.updated_at, + }, + ) async with session_scope(self._factory) as s: - now = get_utc_now() - values = { - **dataclasses.asdict(payload), - "created_at": now, - "updated_at": now, - } - stmt = insert(KnowledgeDocumentRow).values(**values) - update_fields = { - key: values[key] - for key in ( - "app_id", - "project_id", - "category_id", - "title", - "summary", - "source_name", - "source_type", - "md_path", - "updated_at", - ) - } - await s.execute( - stmt.on_conflict_do_update( - index_elements=["doc_id"], - set_=update_fields, - ) - ) + await s.execute(stmt) await s.commit() -def _apply_topic_fields(row: KnowledgeTopicRow, payload: TopicUpsertPayload) -> None: - """Copy payload fields onto an existing SQLAlchemy row.""" - row.doc_id = payload.doc_id - row.app_id = payload.app_id - row.project_id = payload.project_id - row.category_id = payload.category_id - row.topic_index = payload.topic_index - row.topic_name = payload.topic_name - row.topic_path = payload.topic_path - row.depth = payload.depth - row.parent_node_id = payload.parent_node_id - row.children_node_ids = payload.children_node_ids - row.summary = payload.summary - row.content = payload.content - row.content_labels = payload.content_labels - row.md_path = payload.md_path - - class _KnowledgeTopicRepo(RepoBase[KnowledgeTopicRow]): """SQLite repository for ``knowledge_topics`` table.""" @@ -316,25 +277,38 @@ async def delete_by_doc_id(self, doc_id: str) -> int: async def upsert_from_handler(self, payload: TopicUpsertPayload) -> None: """Insert or update a topic row from the cascade handler. - Checks by ``node_id`` (PK). If the row exists, all mutable - columns are overwritten and ``updated_at`` bumps. If not, a - fresh row is inserted with both ``created_at`` and - ``updated_at`` set to now. + Uses SQLite ``INSERT ... ON CONFLICT DO UPDATE`` to avoid + ``StaleDataError`` from concurrent cascade handlers. """ + now = get_utc_now() + values = { + **dataclasses.asdict(payload), + "created_at": now, + "updated_at": now, + } + stmt = sqlite_insert(KnowledgeTopicRow).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=["node_id"], + set_={ + "doc_id": stmt.excluded.doc_id, + "app_id": stmt.excluded.app_id, + "project_id": stmt.excluded.project_id, + "category_id": stmt.excluded.category_id, + "topic_index": stmt.excluded.topic_index, + "topic_name": stmt.excluded.topic_name, + "topic_path": stmt.excluded.topic_path, + "depth": stmt.excluded.depth, + "parent_node_id": stmt.excluded.parent_node_id, + "children_node_ids": stmt.excluded.children_node_ids, + "summary": stmt.excluded.summary, + "content": stmt.excluded.content, + "content_labels": stmt.excluded.content_labels, + "md_path": stmt.excluded.md_path, + "updated_at": stmt.excluded.updated_at, + }, + ) async with session_scope(self._factory) as s: - existing = await s.get(KnowledgeTopicRow, payload.node_id) - now = get_utc_now() - if existing is not None: - _apply_topic_fields(existing, payload) - existing.updated_at = now - s.add(existing) - else: - row = KnowledgeTopicRow( - **dataclasses.asdict(payload), - created_at=now, - updated_at=now, - ) - s.add(row) + await s.execute(stmt) await s.commit() diff --git a/src/everos/infra/persistence/sqlite/tables/unprocessed_buffer.py b/src/everos/infra/persistence/sqlite/tables/unprocessed_buffer.py index 1e563817b..b38f0bae3 100644 --- a/src/everos/infra/persistence/sqlite/tables/unprocessed_buffer.py +++ b/src/everos/infra/persistence/sqlite/tables/unprocessed_buffer.py @@ -15,7 +15,7 @@ class UnprocessedBuffer(BaseTable, table=True): - """One row per unprocessed message. PK ``message_id``.""" + """One row per unprocessed message. PK ``(message_id, app_id, project_id)``.""" __tablename__ = "unprocessed_buffer" # type: ignore[assignment] __table_args__ = ( @@ -32,8 +32,8 @@ class UnprocessedBuffer(BaseTable, table=True): ) message_id: str = Field(primary_key=True) - app_id: str = Field(default="default") - project_id: str = Field(default="default") + app_id: str = Field(default="default", primary_key=True) + project_id: str = Field(default="default", primary_key=True) """App / project scope segments (default ``"default"``).""" session_id: str = Field(index=True) track: str = Field(index=True) diff --git a/src/everos/memory/cascade/handlers/episode.py b/src/everos/memory/cascade/handlers/episode.py index 590c7f613..e28ac8c87 100644 --- a/src/everos/memory/cascade/handlers/episode.py +++ b/src/everos/memory/cascade/handlers/episode.py @@ -21,7 +21,8 @@ ``sections``: -- ``Subject`` (optional): one-line topic. +- ``Subject`` (optional): one-line topic — embedded into + ``subject_vector`` and appended to the BM25 tokenization source. - ``Summary`` (optional): condensed narrative. - ``Content``: full episode narrative — fed to the embedder AND the tokenizer for the ``episode_tokens`` BM25 field. @@ -29,6 +30,8 @@ from __future__ import annotations +import asyncio + from everos.infra.persistence.lancedb import Episode, ParentType, episode_repo from ._common import parse_inline_list, require_iso_timestamp @@ -66,8 +69,22 @@ async def _build_row( ) -> Episode: s = entry.structured text = s.sections.get("Content", "").strip() - tokens = self._deps.tokenizer.tokenize(text) - vector = await self._deps.embedder.embed(text) + subject_text = s.sections.get("Subject", "").strip() + + # Embed content and subject concurrently; skip subject embed when absent. + if subject_text: + vector, subject_vector = await asyncio.gather( + self._deps.embedder.embed(text), + self._deps.embedder.embed(subject_text), + ) + else: + vector = await self._deps.embedder.embed(text) + subject_vector = None + + # BM25 tokenization covers both body and subject keywords. + tokenize_source = f"{text} {subject_text}" if subject_text else text + tokens = self._deps.tokenizer.tokenize(tokenize_source) + return Episode( id=f"{owner_id}_{entry.entry_id}", entry_id=entry.entry_id, @@ -87,4 +104,5 @@ async def _build_row( md_path=md_path, content_sha256=entry.content_sha256, vector=vector, + subject_vector=subject_vector, ) diff --git a/src/everos/memory/cascade/scanner.py b/src/everos/memory/cascade/scanner.py index 13c34e5fc..29ff1bdea 100644 --- a/src/everos/memory/cascade/scanner.py +++ b/src/everos/memory/cascade/scanner.py @@ -105,7 +105,7 @@ async def _run_loop(self) -> None: while not self._stop.is_set(): try: await self.scan_once() - except Exception as exc: # noqa: BLE001 — never crash the daemon + except Exception as exc: logger.exception("cascade_scanner_failed", error=str(exc)) try: await asyncio.wait_for(self._stop.wait(), timeout=self._interval) diff --git a/src/everos/memory/cascade/watcher.py b/src/everos/memory/cascade/watcher.py index 49f2a9b3d..9c30032b9 100644 --- a/src/everos/memory/cascade/watcher.py +++ b/src/everos/memory/cascade/watcher.py @@ -140,7 +140,7 @@ async def _enqueue_async( change_type=change_type, mtime=mtime, ) - except Exception as exc: # noqa: BLE001 — defensive: never crash watcher + except Exception as exc: logger.warning( "cascade_watcher_upsert_failed", md_path=rel, diff --git a/src/everos/memory/cascade/worker.py b/src/everos/memory/cascade/worker.py index 9e2cc5b9e..e82622743 100644 --- a/src/everos/memory/cascade/worker.py +++ b/src/everos/memory/cascade/worker.py @@ -22,10 +22,14 @@ After a batch completes, each kind that mutated its LanceDB table is passed to :meth:`_schedule_optimize` — a per-kind throttle + trailing edge scheduler that fires LanceDB ``optimize()`` as a separate task, -so the drain loop is never blocked by index maintenance. A 60-second -heartbeat sweeps every kind through the same gate so unindexed -fragments don't linger after a worker restart even without new -writes. See :meth:`_schedule_optimize` for the exact semantics. +so the drain loop is never blocked by index maintenance. ``optimize()`` +is a performance/storage-hygiene step, **not** a visibility one: new +rows are searchable immediately via LanceDB's flat-scan over the +unindexed tail (see :meth:`LanceRepoBase.optimize`), so optimizing only +keeps that tail small and prunes dead files. A 60-second heartbeat +sweeps every kind through the same gate so an unindexed tail doesn't +accumulate after a worker restart even without new writes. See +:meth:`_schedule_optimize` for the exact semantics. A separate 12-hour loop (:meth:`_rebuild_loop`) does a full ``drop_index + create_index`` per kind to bound the **active** index @@ -57,7 +61,7 @@ DEFAULT_MAX_RETRY = 3 DEFAULT_POLL_INTERVAL_SECONDS = 1.0 DEFAULT_RETRY_BACKOFF_SECONDS = 2.0 -DEFAULT_OPTIMIZE_MIN_INTERVAL_SECONDS = 1.0 +DEFAULT_OPTIMIZE_MIN_INTERVAL_SECONDS = 10.0 DEFAULT_OPTIMIZE_HEARTBEAT_SECONDS = 60.0 DEFAULT_OPTIMIZE_REBUILD_INTERVAL_SECONDS = 12 * 60 * 60.0 """How often (per kind) to do a full ``drop_index + create_index`` rebuild. @@ -97,7 +101,7 @@ default ``ulimit -n`` of 1024 — the ``os error 24`` reported in CI). The prune itself is cheap when scoped to recent versions; we just don't -want to pay it on every 1-second throttle tick. 5 minutes is the +want to pay it on every optimize throttle tick. 5 minutes is the shortest interval that comfortably outlives any in-flight query / index build, while keeping the on-disk footprint bounded. It is also passed as ``cleanup_older_than`` itself (semantically: "the retention window @@ -231,10 +235,12 @@ async def drain_once(self) -> int: For each kind that mutated its LanceDB table this batch, :meth:`_schedule_optimize` records a throttled optimize intent. The actual ``optimize()`` runs as a separate task so - drain throughput is decoupled from index maintenance; callers - that need a "fully indexed" barrier (CLI ``cascade sync``) - must call :meth:`_flush_optimizers` — :meth:`drain_until_empty` - does this on their behalf. + drain throughput is decoupled from index maintenance. Drained + rows are already searchable at this point (flat-scan over the + unindexed tail); callers that additionally want the index fully + merged before returning (CLI ``cascade sync``) call + :meth:`_flush_optimizers` — :meth:`drain_until_empty` does this + on their behalf. """ batch = await md_change_state_repo.claim_pending_batch(self._batch_size) if not batch: @@ -254,8 +260,9 @@ async def drain_until_empty(self, *, max_passes: int = 100) -> int: cheap safety net). Awaits :meth:`_flush_optimizers` before returning so callers - (CLI ``cascade sync``) can rely on FTS being up-to-date when - the call completes. + (CLI ``cascade sync``) get a fully merged index — not for + visibility (the data is already searchable) but so ``sync`` + returns a deterministically optimized state. """ total = 0 for _ in range(max_passes): @@ -272,7 +279,7 @@ async def _run_loop(self) -> None: while not self._stop.is_set(): try: processed = await self.drain_once() - except Exception as exc: # noqa: BLE001 — never crash the daemon + except Exception as exc: logger.exception("cascade_worker_drain_failed", error=str(exc)) processed = 0 if processed == 0: @@ -330,7 +337,7 @@ async def _process_one(self, row: MdChangeState) -> str | None: new_retry_count=retry_count, ) return None - except Exception as exc: # noqa: BLE001 — surface as unrecoverable + except Exception as exc: last_error = f"{type(exc).__name__}: {exc}" logger.exception( "cascade_worker_unrecoverable", @@ -483,7 +490,7 @@ async def _run_optimize_once(self, kind: str) -> None: kind=kind, pruned=should_prune, ) - except Exception as exc: # noqa: BLE001 — never crash the daemon + except Exception as exc: logger.warning( "cascade_lancedb_optimize_failed", kind=kind, @@ -496,11 +503,13 @@ async def _heartbeat_loop(self) -> None: Sweeps every kind through :meth:`_schedule_optimize` once per ``optimize_heartbeat_seconds``. Without this, a worker that - restarts with stale unindexed fragments (e.g. after a crash - between write and optimize) would only catch up once new - writes arrive. The sweep goes through the same throttle gate - so it can never storm — kinds with an in-flight optimize or - a fresh ``last_run_at`` are coalesced. + restarts with an unindexed tail (e.g. after a crash between + write and optimize) would only merge it in once new writes + arrive — those rows stay searchable meanwhile (flat-scan), but + the tail keeps the scan slow and the dead files on disk; the + sweep bounds both. It goes through the same throttle gate so it + can never storm — kinds with an in-flight optimize or a fresh + ``last_run_at`` are coalesced. """ while not self._stop.is_set(): if await self._wait_or_stop(self._optimize_heartbeat): @@ -571,7 +580,7 @@ async def _run_rebuild_once(self, kind: str) -> None: try: await rebuild_task logger.info("cascade_lancedb_rebuilt", kind=kind) - except Exception as exc: # noqa: BLE001 — never crash the daemon + except Exception as exc: logger.warning( "cascade_lancedb_rebuild_failed", kind=kind, @@ -586,8 +595,10 @@ async def _flush_optimizers(self) -> None: Drain-loop path is fire-and-forget for throughput; this is the explicit barrier used by CLI ``cascade sync`` and worker - shutdown to ensure FTS is consistent with the data on disk - before the call returns. + shutdown to let in-flight optimizes finish merging the unindexed + tail before the call returns. Not a visibility barrier — drained + rows are searchable via flat-scan regardless; this just yields a + fully merged index (and, on shutdown, no orphaned mid-write). Exceptions from optimize tasks are already logged in :meth:`_run_optimize_once`; ``return_exceptions=True`` here diff --git a/src/everos/memory/get/filters_adapter.py b/src/everos/memory/get/filters_adapter.py index 2533aeb72..407a06bc7 100644 --- a/src/everos/memory/get/filters_adapter.py +++ b/src/everos/memory/get/filters_adapter.py @@ -25,7 +25,6 @@ def compile_filters_for_get( owner_type: str, app_id: str = "default", project_id: str = "default", - exclude_deprecated: bool = True, ) -> str: """Compile ``/get`` filters via the shared ``compile_filters`` path. @@ -38,5 +37,4 @@ def compile_filters_for_get( owner_type=owner_type, app_id=app_id, project_id=project_id, - exclude_deprecated=exclude_deprecated, ) diff --git a/src/everos/memory/get/manager.py b/src/everos/memory/get/manager.py index dd299a34f..1000cbdd2 100644 --- a/src/everos/memory/get/manager.py +++ b/src/everos/memory/get/manager.py @@ -76,7 +76,6 @@ async def get(self, req: GetRequest) -> GetResponse: owner_type=req.owner_type, app_id=req.app_id, project_id=req.project_id, - exclude_deprecated=req.memory_type == GetMemoryType.EPISODE, ) match req.memory_type: diff --git a/src/everos/memory/search/adapter.py b/src/everos/memory/search/adapter.py index cd05d9b32..2d7cef36b 100644 --- a/src/everos/memory/search/adapter.py +++ b/src/everos/memory/search/adapter.py @@ -6,8 +6,8 @@ ``ValueError`` as a defensive guard. * ``KEYWORD`` / ``VECTOR`` → ``None`` → manager skips ``everalgo.rank``. -* ``HYBRID`` → ``"hierarchy"`` (episode / atomic_fact) — four-layer pipeline - (RRF → MaxSim → RRF merge → single-pass eviction) +* ``HYBRID`` → ``"hierarchy"`` (episode / atomic_fact) — heap-expand + pipeline (RRF-ordered expansion → LR-calibrated global top-N competition) or ``"vector_anchored"`` (agent_case) — everalgo vector-anchored fusion (alpha=0.7) or ``"skill_hybrid"`` (agent_skill) — custom rrf → cross-encoder rerank → optional verify. @@ -30,8 +30,8 @@ def resolve_pipeline( ``pipeline_signal`` of ``None`` means "do not call ``everalgo.rank.arank``; the manager runs single-route recall and returns directly". - ``"hierarchy"`` routes to the four-layer episode pipeline in - ``memory.search.hierarchy`` (RRF → MaxSim → RRF merge → eviction). + ``"hierarchy"`` routes to the heap-expand episode pipeline in + ``memory.search.hierarchy`` (RRF → LR → heap expansion → eviction). ``"vector_anchored"`` routes to ``everalgo.rank.arank`` with vector-anchored fusion (alpha=0.7, saturation_k=5.0) — matches the opensource case retrieval. ``"skill_hybrid"`` routes to the custom skill hybrid orchestrator in diff --git a/src/everos/memory/search/agentic.py b/src/everos/memory/search/agentic.py index 48a54d7dd..6abcf7373 100644 --- a/src/everos/memory/search/agentic.py +++ b/src/everos/memory/search/agentic.py @@ -20,6 +20,7 @@ from __future__ import annotations +import asyncio import datetime as _dt from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any @@ -31,12 +32,15 @@ from everalgo.types import Candidate from everos.component.utils.datetime import from_timestamp, to_timestamp_ms +from everos.core.observability.logging import get_logger from everos.infra.persistence.sqlite import cluster_repo from everos.memory.search.callbacks import build_rerank_fn from everos.memory.search.shaper import shape_episode_from_candidate from .dto import SearchEpisodeItem +logger = get_logger(__name__) + if TYPE_CHECKING: from everalgo.clustering import Cluster from everalgo.llm.protocols import LLMClient @@ -76,6 +80,8 @@ async def search_episodes_agentic( *, owner_id: str, where: str, + app_id: str = "default", + project_id: str = "default", episode_recaller: EpisodeRecaller, atomic_fact_recaller: AtomicFactRecaller, embed_query_fn: Callable[[str], Awaitable[list[float]]], @@ -101,15 +107,23 @@ async def search_episodes_agentic( Empty when no clusters exist or retrieval returns nothing. """ - # 1. Fact-level child retrieve closures (dense + sparse via atomic_fact table). + # 1. Fact-level child retrieve closures (dense + sparse via both tables). async def _fact_dense(q: str, k: int) -> list[Candidate]: vec = await embed_query_fn(q) if not vec: return [] - return await atomic_fact_recaller.dense_recall(vec, where, limit=k) + fact_results, ep_results = await asyncio.gather( + atomic_fact_recaller.dense_recall(vec, where, limit=k), + episode_recaller.dense_recall_subject_as_child(vec, where, limit=k), + ) + return fact_results + ep_results async def _fact_sparse(q: str, k: int) -> list[Candidate]: - return await atomic_fact_recaller.sparse_recall(q, where, limit=k) + fact_results, ep_results = await asyncio.gather( + atomic_fact_recaller.sparse_recall(q, where, limit=k), + episode_recaller.sparse_recall_as_child(q, where, limit=k), + ) + return fact_results + ep_results # 2. parent_fetch: maps entry_ids (from atomic_fact.parent_id) to episodes. # Atomic facts always point to episodes via entry_id regardless of @@ -168,7 +182,12 @@ async def hybrid_full(q: str, k: int) -> list[Candidate]: # Reshape metadata to the everalgo doc contract so the sufficiency / # multi-query LLM prompt (rendered by ``_format_docs``) sees the episode # body and a ms-epoch date instead of the memcell id. - clusters: list[Cluster] = await cluster_repo.list_for_owner(owner_id, "user_memory") + clusters: list[Cluster] = await cluster_repo.list_for_owner( + owner_id, + "user_memory", + app_id=app_id, + project_id=project_id, + ) raw_all_docs = await episode_recaller.fetch_all_for_owner(where) all_docs: list[Candidate] = [ c.model_copy(update={"metadata": _to_everalgo_doc_metadata(c.metadata)}) @@ -192,7 +211,7 @@ async def cluster_scoped(q: str, _k: int) -> list[Candidate]: ) # 8. aagentic_retrieve — benchmark cluster main path. - candidates, _decision = await aagentic_retrieve( + candidates, decision = await aagentic_retrieve( query, base_retrieve=cluster_scoped, round2_retrieve=hybrid_full, @@ -207,6 +226,9 @@ async def cluster_scoped(q: str, _k: int) -> list[Candidate]: rrf_k=_HYBRID_RRF_K, ) + round_tag = "round1" if decision.is_sufficient else "round2" + logger.info("agentic_search_decision", round=round_tag, query=query[:80]) + # 9. Shape: remap id from memcell_id -> episode_id, then build DTO. return _shape_results(candidates) diff --git a/src/everos/memory/search/dto.py b/src/everos/memory/search/dto.py index 6f7b1c733..d825be5f9 100644 --- a/src/everos/memory/search/dto.py +++ b/src/everos/memory/search/dto.py @@ -81,19 +81,19 @@ class SearchRequest(BaseModel): top_k: int = -1 radius: float | None = Field(default=None, ge=0.0, le=1.0) min_score: float | None = Field(default=None, ge=0.0, le=1.0) - """Post-fusion relevance floor for the episode HYBRID (hierarchy) path. + """Post-fusion relevance floor for the episode HYBRID path. - Applied after Layer 4 against the LR-calibrated score in ``[0, 1]``: + Applied after heap-expand against the LR-calibrated score in ``[0, 1]``: items scoring below this value are dropped. Independent of ``radius`` (which gates raw cosine at recall time); ``None`` disables the floor. - Only the episode hierarchy path consumes it — other methods ignore it. + Only the episode hybrid path consumes it — other methods ignore it. """ include_profile: bool = False enable_llm_rerank: bool = Field( default=False, description=( "Opt-in LLM rerank pass for HYBRID. Applies to agent_case " - "and agent_skill fusion only; the episode hierarchy path " + "and agent_skill fusion only; the episode hybrid path " "has built-in fact eviction and ignores this flag. " "Ignored by keyword / vector / agentic." ), diff --git a/src/everos/memory/search/filters.py b/src/everos/memory/search/filters.py index e80acfa80..9903cb776 100644 --- a/src/everos/memory/search/filters.py +++ b/src/everos/memory/search/filters.py @@ -35,7 +35,7 @@ from typing import Any, Final from everos.component.utils.datetime import from_timestamp, to_iso_format -from everos.core.errors import FilterError as FilterError # noqa: F401 +from everos.core.errors import FilterError as FilterError from .dto import FilterNode @@ -92,7 +92,6 @@ def compile_filters( owner_type: str, app_id: str = "default", project_id: str = "default", - exclude_deprecated: bool = True, ) -> str: """Compile a request's filters into a single LanceDB ``where`` string. @@ -108,9 +107,8 @@ def compile_filters( f"owner_type = '{owner_type}'", f"app_id = '{_escape_str(app_id)}'", f"project_id = '{_escape_str(project_id)}'", + "deprecated_by IS NULL", ] - if exclude_deprecated: - base.append("deprecated_by IS NULL") if node is None: return " AND ".join(base) compiled = _compile_node(node.model_dump(exclude_none=True)) diff --git a/src/everos/memory/search/hierarchy.py b/src/everos/memory/search/hierarchy.py index 3b8b4f840..11508a654 100644 --- a/src/everos/memory/search/hierarchy.py +++ b/src/everos/memory/search/hierarchy.py @@ -1,305 +1,195 @@ -"""Hierarchical episode retrieval — two-path recall fused with per-fact eviction. +"""Heap-expand hierarchy — heap-driven lazy expansion with global top-N competition. -Episode HYBRID search path: combines episode-level hybrid recall (Layer 1) -with fact-driven MaxSim re-scoring (Layer 2), merges via RRF (Layer 3), then -runs a hierarchical fact eviction where parent episode and its facts compete on a -single LR-calibrated scale and the best fact replaces the episode when it -wins (Layer 4). +Replaces the four-layer serial pipeline (hierarchy.py) with a heap-based +approach where RRF orders the expansion priority and LR-calibrated scores +drive the global competition between episodes and their atomic facts. -Uses everalgo operators as pure algorithm primitives; all I/O is injected -via recaller callbacks. No changes to the everalgo library are required. +Pure synchronous module — zero I/O, zero async. Designed for zero-change +migration to everalgo. """ from __future__ import annotations +import heapq from typing import TYPE_CHECKING -from everalgo.rank import amaxsim_retrieve -from everalgo.rank.fusion import cosine_to_lr_score, rrf +from everalgo.rank.fusion import cosine_to_lr_score, lr, rrf from everalgo.types import Candidate, FactCandidate, ScoredItem -from everos.core.observability.logging import get_logger - -from .dto import SearchEpisodeItem -from .shaper import reshape_hybrid_output - if TYPE_CHECKING: - from collections.abc import Sequence - - from everos.memory.search.recall.atomic_fact import AtomicFactRecaller - from everos.memory.search.recall.episode import EpisodeRecaller + from everalgo.rank.weight import LRCoefs -logger = get_logger(__name__) -_HIERARCHY_ALPHA = 1.0 -_HIERARCHY_FACTS_PER_EPISODE = 3 - - -async def hierarchy_retrieve_episodes( - query: str, - *, - sparse: list[Candidate], - dense: list[Candidate], - query_vector: list[float], - fact_recaller: AtomicFactRecaller, - episode_recaller: EpisodeRecaller, - where: str, - top_k: int, - fact_child_candidates: int = 200, - alpha: float = _HIERARCHY_ALPHA, - min_score: float | None = None, -) -> list[SearchEpisodeItem]: - """Run the four-layer hierarchical episode retrieval pipeline. - - Layer 1: RRF fusion over pre-recalled sparse + dense episode candidates. - Layer 2: MaxSim re-score via atomic-fact child retrieval (fact cosine ANN - → group by parent memcell → episode re-score by best fact). - Layer 3: RRF merge of Layer-1 and Layer-2 results, sliced to top_k. - Layer 4: Pre-fetch facts for merged episodes, then hierarchical eviction — - parent and facts compete on one LR-calibrated scale; the best - fact replaces its episode when it wins. +def build_ep_to_fact_parents( + episodes: list[Candidate], +) -> dict[str, list[str]]: + """Map episode candidate id to possible fact parent_id values. Args: - query: Raw query string passed to amaxsim_retrieve. - sparse: BM25 episode candidates from the caller's recall phase. - dense: Vector ANN episode candidates from the caller's recall phase. - query_vector: Pre-computed query embedding; reused for fact ANN recall - and per-fact scoring in facts_for_episodes. - fact_recaller: AtomicFactRecaller instance for child retrieval and - facts_for_episodes. - episode_recaller: EpisodeRecaller instance for MaxSim parent fetch. - where: LanceDB filter clause (owner scope, tenant, etc.). - top_k: Maximum number of items in the final merged slice before eviction. - fact_child_candidates: How many atomic-fact ANN candidates to pull in - Layer 2. Default 200. - alpha: Child (fact) weight in the Layer-4 LR-scale blend. Default - ``_HIERARCHY_ALPHA``. - min_score: Optional post-Layer-4 relevance floor on the LR-calibrated - score in ``[0, 1]``; items below it are dropped. ``None`` disables. + episodes: Episode candidate list. Returns: - Shaped SearchEpisodeItem list (episodes with nested atomic_facts), - sorted by score descending, each carrying an LR-calibrated score. + Dict mapping episode id to parent_ids (entry_id and/or memcell_id). """ - # Layer 1 — episode RRF fusion - layer1_episodes = rrf(sparse, dense) - - # Layer 2 — MaxSim re-score via atomic-fact child retrieval - layer2_episodes = await _maxsim_episode_rescore( - query=query, - query_vector=query_vector, - fact_recaller=fact_recaller, - episode_recaller=episode_recaller, - where=where, - child_candidates=fact_child_candidates, - ) - - # Layer 3 — RRF merge of episode-level results, slice to top_k - merged = rrf(layer1_episodes, layer2_episodes)[:top_k] - - if not merged: - logger.info("hierarchy_retrieve_empty_merge", top_k=top_k) - return [] - - # Layer 4a — pre-fetch facts for merged episodes - ep_to_parents = _build_ep_to_fact_parents(merged) - episode_to_facts = await fact_recaller.facts_for_episodes( - ep_to_parents, - where, - per_episode=max(top_k * 2, 20), - query_vector=query_vector, - ) - - ep_cosine: dict[str, float] = {} - for c in (*dense, *layer2_episodes): - if c.id: - ep_cosine[c.id] = max(ep_cosine.get(c.id, 0.0), c.score) - ep_bm25 = {c.id: c.score for c in sparse if c.id} - - scored_items = _hierarchy_eviction_pass( - merged, - episode_to_facts, - ep_cosine=ep_cosine, - ep_bm25=ep_bm25, - alpha=alpha, - ) - - # Build episode pool for orphan fact parent lookup. - # Include layer2_episodes so episodes surfaced only via MaxSim path - # (not in the original sparse/dense recall) can still serve as parent. - episode_pool = {c.id: c for c in (*sparse, *dense, *layer2_episodes)} - - shaped = reshape_hybrid_output(scored_items, episode_pool=episode_pool) - - # Post-Layer-4 relevance floor on the LR-calibrated score. - if min_score is not None: - shaped = [item for item in shaped if item.score >= min_score] - return shaped + result: dict[str, list[str]] = {} + for ep in episodes: + parents: list[str] = [] + entry_id = ep.metadata.get("entry_id") + if isinstance(entry_id, str) and entry_id: + parents.append(entry_id) + parent_id = ep.metadata.get("parent_id") + if isinstance(parent_id, str) and parent_id and parent_id != entry_id: + parents.append(parent_id) + if parents: + result[ep.id] = parents + return result -def _hierarchy_eviction_pass( - merged: list[Candidate], - episode_to_facts: dict[str, list[FactCandidate]], +def heap_expand( *, - ep_cosine: dict[str, float], - ep_bm25: dict[str, float], - alpha: float = _HIERARCHY_ALPHA, - facts_per_episode: int = _HIERARCHY_FACTS_PER_EPISODE, + sparse: list[Candidate], + dense: list[Candidate], + episode_to_facts: dict[str, list[FactCandidate]], + top_k: int = 10, + alpha: float = 1.0, + rrf_k: int = 60, + max_convergence_rounds: int = 10, + facts_per_episode: int = 3, + lr_coefs: LRCoefs | None = None, ) -> list[ScoredItem]: - """Hierarchical fact eviction: parent and facts compete on one LR-calibrated scale. - - For each merged episode the parent and its candidate facts are calibrated - to an LR probability via ``cosine_to_lr_score`` so a raw fact cosine and an - episode's recall relevance become directly comparable (replacing the prior - cosine-vs-RRF comparison, which mixed scales). Each fact's blended score is - ``alpha * child_lr + (1 - alpha) * parent_lr``; the single best-scoring - fact replaces the episode (eviction) when it beats the parent's own LR - score, otherwise the episode is emitted at ``parent_lr``. + """Heap-expand hierarchy: RRF orders expansion, LR scores drive competition. Args: - merged: RRF-merged episode candidates, ordered by descending score. - Their ``.score`` (RRF) is used only for ordering, not for scoring. - episode_to_facts: Map from episode id to its pre-fetched FactCandidates, - sorted by cosine similarity descending. - ep_cosine: Per-episode best cosine relevance (dense / MaxSim routes). - ep_bm25: Per-episode BM25 score (sparse route); ``0.0`` when absent. - alpha: Child (fact) weight in the blend; ``1.0`` lets the fact's own - calibrated relevance fully drive the blended score. - facts_per_episode: Max facts per episode entered into the competition. + sparse: BM25 episode candidates (descending by BM25 score). + dense: Vector ANN episode candidates (descending by cosine). + episode_to_facts: Pre-fetched facts per episode, each list sorted + by cosine descending. + top_k: Maximum items in the final output. + alpha: Fact weight in the blend (1.0 = fact score only). + rrf_k: RRF constant (default 60). + max_convergence_rounds: Stop after this many consecutive rounds + with no top-N change. + facts_per_episode: Max facts per episode to enter competition. + lr_coefs: Override LR coefficients; None uses production defaults. Returns: - Mixed list of ScoredItem instances (episodes and atomic_facts), each - carrying an LR-calibrated ``score`` in ``[0, 1]``, ready for - reshape_hybrid_output. + Mixed list of ScoredItem (episodes + atomic_facts), sorted by + score descending. Ready for ``reshape_hybrid_output``. """ - out: list[ScoredItem] = [] + if not sparse and not dense: + return [] - for episode in merged: - parent_bm25 = ep_bm25.get(episode.id, 0.0) - parent_cosine = ep_cosine.get(episode.id, 0.0) - parent_lr = cosine_to_lr_score(parent_cosine, parent_bm25) + # Phase 1 — dual fusion + bm25_scores = {c.id: c.score for c in sparse} + lr_results = lr(dense, sparse, coefs=lr_coefs) + episode_scores = {c.id: c.score for c in lr_results} + rrf_results = rrf(sparse, dense, k=rrf_k) - # A fact must strictly beat the parent's LR score to evict it. - best_fact: FactCandidate | None = None - best_blended = parent_lr - for fact in episode_to_facts.get(episode.id, [])[:facts_per_episode]: - child_lr = cosine_to_lr_score(fact.score, parent_bm25) - blended = alpha * child_lr + (1.0 - alpha) * parent_lr - if blended > best_blended: - best_blended = blended - best_fact = fact + if not rrf_results: + return [] - if best_fact is not None: - # Fact wins: emit fact at its blended score; episode becomes orphan parent. - out.append( + # Phase 2 — heap + top-N init + heap: list[tuple[float, str]] = [] + for doc in rrf_results: + heapq.heappush(heap, (-doc.score, doc.id)) + + # topn: {id: (Candidate, lr_score, item_type, source_episode_id)} + topn: dict[str, tuple[Candidate | FactCandidate, float, str, str]] = {} + for doc in rrf_results[:top_k]: + topn[doc.id] = (doc, episode_scores.get(doc.id, 0.0), "episode", doc.id) + + # Phase 3 — heap convergence loop + convergence_count = 0 + while heap and convergence_count < max_convergence_rounds: + _, episode_id = heapq.heappop(heap) + prev_keys = set(topn.keys()) + + _expand_one_episode( + episode_id, + topn=topn, + episode_to_facts=episode_to_facts, + bm25_scores=bm25_scores, + episode_scores=episode_scores, + alpha=alpha, + facts_per_episode=facts_per_episode, + top_k=top_k, + lr_coefs=lr_coefs, + ) + + if set(topn.keys()) == prev_keys: + convergence_count += 1 + else: + convergence_count = 0 + + # Phase 4 — output + sorted_entries = sorted(topn.values(), key=lambda v: v[1], reverse=True) + result: list[ScoredItem] = [] + for item, score, item_type, source_ep_id in sorted_entries: + if item_type == "episode": + result.append( ScoredItem( - id=best_fact.id, - score=best_blended, - item_type="atomic_fact", - metadata=best_fact.metadata, - parent_episode_id=episode.id, + id=item.id, + score=score, + item_type="episode", + metadata=dict(item.metadata), ) ) - logger.debug( - "hierarchy_eviction_fact_wins", - episode_id=episode.id, - fact_id=best_fact.id, - fact_score=best_blended, - episode_score=parent_lr, - ) else: - # Episode wins: emit episode at its LR-calibrated parent score. - out.append( + result.append( ScoredItem( - id=episode.id, - score=parent_lr, - item_type="episode", - metadata=dict(episode.metadata), - parent_episode_id=None, + id=item.id, + score=score, + item_type="atomic_fact", + metadata=dict(item.metadata), + parent_episode_id=source_ep_id, ) ) - - return out - - -# ── Internal helpers ───────────────────────────────────────────────────── + return result -async def _maxsim_episode_rescore( +def _expand_one_episode( + episode_id: str, *, - query: str, - query_vector: list[float], - fact_recaller: AtomicFactRecaller, - episode_recaller: EpisodeRecaller, - where: str, - child_candidates: int, -) -> list[Candidate]: - """Run amaxsim_retrieve to produce MaxSim-rescored episode candidates. - - Atomic facts serve as child documents (their metadata["parent_id"] is - the episode entry_id). Episodes are fetched as parents via - episode_recaller.fetch_by_entry_ids. - - ``amaxsim_retrieve`` calls ``child_retrieve`` exactly once with the - original query string. We reuse the pre-computed ``query_vector`` to - avoid a redundant embed call. - - Args: - query: Raw query string (passed verbatim to amaxsim_retrieve). - query_vector: Pre-computed query embedding; used directly for child - ANN recall, bypassing a second embed call. - fact_recaller: Provides the child ANN retrieval function. - episode_recaller: Provides the parent fetch function. - where: LanceDB filter clause. - child_candidates: Number of atomic-fact candidates to pull per call. - - Returns: - Episode candidates re-scored by their best matching atomic fact. - """ - - async def child_retrieve(_q: str, n: int) -> Sequence[Candidate]: - # amaxsim_retrieve calls this exactly once with the original query string. - # Reuse the pre-computed query_vector instead of re-embedding. - return await fact_recaller.dense_recall(query_vector, where, limit=n) - - async def parent_fetch(entry_ids: list[str]) -> list[Candidate]: - return await episode_recaller.fetch_by_entry_ids(entry_ids, where) - - return await amaxsim_retrieve( - query, - child_retrieve=child_retrieve, - parent_fetch=parent_fetch, - top_n=50, - child_candidates=child_candidates, - ) - - -def _build_ep_to_fact_parents(episodes: list[Candidate]) -> dict[str, list[str]]: - """Map episode candidate id to all possible fact parent_id values. - - New facts (post-1.5): parent_id = episode entry_id. - Old facts (pre-1.5): parent_id = memcell_id (episode.parent_id). - Both are collected so the IN query covers both eras without backfill. - - Invariant: entry_id (ep_*) and memcell_id (mc_*) namespaces never - overlap, so mixing them in one IN clause is safe. - - Args: - episodes: Merged episode candidate list. - - Returns: - Dict mapping episode LanceDB id to a list of candidate parent_ids - (entry_id and/or memcell_id). - """ - result: dict[str, list[str]] = {} - for ep in episodes: - parents: list[str] = [] - entry_id = ep.metadata.get("entry_id") - if isinstance(entry_id, str) and entry_id: - parents.append(entry_id) - parent_id = ep.metadata.get("parent_id") - if isinstance(parent_id, str) and parent_id and parent_id != entry_id: - parents.append(parent_id) - if parents: - result[ep.id] = parents - return result + topn: dict[str, tuple[Candidate | FactCandidate, float, str, str]], + episode_to_facts: dict[str, list[FactCandidate]], + bm25_scores: dict[str, float], + episode_scores: dict[str, float], + alpha: float, + facts_per_episode: int, + top_k: int, + lr_coefs: LRCoefs | None, +) -> None: + """Expand one episode's facts and compete with top-N in place.""" + # Pre-fetch 2× candidates so LR scoring can filter to the best N. + facts = episode_to_facts.get(episode_id, [])[: facts_per_episode * 2] + if not facts: + return + + parent_bm25 = bm25_scores.get(episode_id, 0.0) + parent_lr = episode_scores.get(episode_id, 0.0) + + scored: list[tuple[float, FactCandidate]] = [] + for fact in facts: + child_lr = cosine_to_lr_score(fact.score, parent_bm25, coefs=lr_coefs) + blended = alpha * child_lr + (1.0 - alpha) * parent_lr + scored.append((blended, fact)) + + scored.sort(key=lambda t: t[0], reverse=True) + top_facts = scored[:facts_per_episode] + + min_topn_score = min((v[1] for v in topn.values()), default=0.0) if topn else 0.0 + any_entered = False + + for fact_score, fact in top_facts: + if fact_score <= 0.0: + continue + if len(topn) < top_k or fact_score > min_topn_score: + topn[fact.id] = (fact, fact_score, "atomic_fact", episode_id) + any_entered = True + if len(topn) > top_k: + worst_id = min(topn, key=lambda k: topn[k][1]) + del topn[worst_id] + min_topn_score = min((v[1] for v in topn.values()), default=0.0) + + if any_entered and episode_id in topn: + del topn[episode_id] diff --git a/src/everos/memory/search/manager.py b/src/everos/memory/search/manager.py index 4fd03d792..7633df415 100644 --- a/src/everos/memory/search/manager.py +++ b/src/everos/memory/search/manager.py @@ -12,8 +12,8 @@ selected without its prerequisites. ``HYBRID`` defaults to **no LLM rerank** — the response comes back -straight after the four-layer hierarchy pipeline (RRF → MaxSim → -RRF merge → single-pass fact eviction). ``enable_llm_rerank`` is +straight after the heap-expand pipeline (RRF-ordered expansion → LR-calibrated +global top-N competition with fact eviction). ``enable_llm_rerank`` is **ignored** for the hierarchy path. ``AGENTIC`` keeps its own internal cross-encoder rerank loop; the flag is ignored there. @@ -33,10 +33,10 @@ from typing import TYPE_CHECKING from everalgo.rank import DEFAULT_RANK_CONFIG, RankConfig, arank +from everalgo.rank.fusion import rrf from everalgo.types import Candidate, RankInput from everos.component.utils.datetime import to_display_tz -from everos.config import load_settings from everos.core.observability.logging import get_logger from everos.core.observability.tracing import gen_request_id from everos.infra.persistence.sqlite import ( @@ -60,8 +60,9 @@ UnprocessedMessageDTO, ) from .filters import compile_filters -from .hierarchy import hierarchy_retrieve_episodes +from .hierarchy import build_ep_to_fact_parents, heap_expand from .shaper import ( + reshape_hybrid_output, shape_agent_case_from_candidate, shape_agent_skill_from_candidate, shape_episode_from_candidate, @@ -161,7 +162,6 @@ async def search(self, req: SearchRequest) -> SearchResponse: owner_type=req.owner_type, app_id=req.app_id, project_id=req.project_id, - exclude_deprecated=req.owner_type == "user", ) self._validate_components(req) @@ -251,6 +251,8 @@ async def _search_episodes( req.query, owner_id=req.owner_id, where=where, + app_id=req.app_id, + project_id=req.project_id, episode_recaller=self._ep, atomic_fact_recaller=self._fact, embed_query_fn=self._embedding.embed, # type: ignore[union-attr] @@ -269,14 +271,8 @@ async def _search_episodes( cands = await self._ep.sparse_recall( req.query, where, limit=self._recall_limit(req.top_k) ) - elif load_settings().search.vector_strategy == "maxsim_atomic": - cands = await self._maxsim_atomic_recall(req, where, top_k) else: - vector = await self._embed_query(req.query) - cands = await self._ep.dense_recall( - vector, where, limit=self._recall_limit(req.top_k) - ) - cands = self._apply_radius(cands, _effective_radius(req)) + cands = await self._maxsim_atomic_recall(req, where, top_k) # ``atomic_facts`` stays empty: facts come back only when the HYBRID # pipeline surfaces them with a score (see ``reshape_hybrid_output``). # Single-route recall has no per-fact score against the query, so @@ -294,17 +290,25 @@ async def _search_episodes( ) if fusion_mode == "hierarchy": - return await hierarchy_retrieve_episodes( - req.query, + rrf_candidates = rrf(sparse, dense) + ep_to_parents = build_ep_to_fact_parents(rrf_candidates) + episode_to_facts = await self._fact.facts_for_episodes( + ep_to_parents, + where, + per_episode=max(top_k * 2, 20), + query_vector=query_vector, + ) + scored = heap_expand( sparse=sparse, dense=dense, - query_vector=query_vector, - fact_recaller=self._fact, - episode_recaller=self._ep, - where=where, + episode_to_facts=episode_to_facts, top_k=top_k, - min_score=req.min_score, ) + episode_pool = {c.id: c for c in (*sparse, *dense)} + shaped = reshape_hybrid_output(scored, episode_pool=episode_pool) + if req.min_score is not None: + shaped = [s for s in shaped if s.score >= req.min_score] + return shaped # rrf / lr: standard everalgo fusion path (fallback). output = await arank( diff --git a/src/everos/memory/search/recall/base.py b/src/everos/memory/search/recall/base.py index cd9c87497..9b62c10f9 100644 --- a/src/everos/memory/search/recall/base.py +++ b/src/everos/memory/search/recall/base.py @@ -37,7 +37,7 @@ # is huge (1024 floats); ``_distance`` belongs to LanceDB's query engine # and is converted into ``score`` before the row leaves the recaller. _NOISE_COLUMNS: frozenset[str] = frozenset( - {"vector", "_distance", "_score", "created_at", "updated_at"} + {"vector", "subject_vector", "_distance", "_score", "created_at", "updated_at"} ) diff --git a/src/everos/memory/search/recall/episode.py b/src/everos/memory/search/recall/episode.py index 6dd329348..a2766dcec 100644 --- a/src/everos/memory/search/recall/episode.py +++ b/src/everos/memory/search/recall/episode.py @@ -17,6 +17,19 @@ ) +def _inject_parent_id(candidates: list[Candidate]) -> list[Candidate]: + """Wrap candidates with ``parent_id`` for MaxSim group-by-parent.""" + return [ + Candidate( + id=c.id, + score=c.score, + source=c.source, + metadata={**c.metadata, "parent_id": c.metadata.get("entry_id", c.id)}, + ) + for c in candidates + ] + + def _q(value: str) -> str: return value.replace("'", "''") @@ -63,6 +76,48 @@ async def dense_recall( rows = ( await table.query() .nearest_to(list(vector)) + .column("vector") + .distance_type("cosine") + .where(where) + .limit(limit) + .to_list() + ) + return [ + row_to_candidate( + r, + source="vector", + score=cosine_score_from_distance(r.get("_distance")), + ) + for r in rows + ] + + async def sparse_recall_as_child( + self, query: str, where: str, *, limit: int + ) -> list[Candidate]: + """Sparse recall returning episodes as MaxSim child candidates.""" + return _inject_parent_id(await self.sparse_recall(query, where, limit=limit)) + + async def dense_recall_as_child( + self, vector: Sequence[float], where: str, *, limit: int + ) -> list[Candidate]: + """Dense recall (body vector ANN) returning as MaxSim children.""" + return _inject_parent_id(await self.dense_recall(vector, where, limit=limit)) + + async def dense_recall_subject( + self, vector: Sequence[float], where: str, *, limit: int + ) -> list[Candidate]: + """ANN over the ``subject_vector`` column. + + Rows with ``subject_vector=None`` are naturally excluded by + LanceDB ANN. + """ + if not vector: + return [] + table = await get_table(Episode.TABLE_NAME, Episode) + rows = ( + await table.query() + .nearest_to(list(vector)) + .column("subject_vector") .distance_type("cosine") .where(where) .limit(limit) @@ -77,6 +132,13 @@ async def dense_recall( for r in rows ] + async def dense_recall_subject_as_child( + self, vector: Sequence[float], where: str, *, limit: int + ) -> list[Candidate]: + """Subject-vector ANN returning as MaxSim children.""" + candidates = await self.dense_recall_subject(vector, where, limit=limit) + return _inject_parent_id(candidates) + async def fetch_all_for_owner(self, where: str) -> list[Candidate]: """Flat scan — all episodes for this owner, keyed by entry_id. diff --git a/src/everos/memory/strategies/_partition_locks.py b/src/everos/memory/strategies/_partition_locks.py deleted file mode 100644 index a4e3b8d93..000000000 --- a/src/everos/memory/strategies/_partition_locks.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Per-strategy partition locks for serialising RMW critical sections. - -The OME engine intentionally does NOT serialise concurrent runs of the -same strategy -(``local/specs/2026-04-27-ome-tech-design.md`` §4.5.2: the business logic -must guard itself, deciding inside the strategy body via -``async with lock`` bucketed by a business key). Offline strategies whose -body is a read → -modify → write on shared state (cluster rows, user.md, SKILL.md) -serialise on a business key (``owner_id`` / ``agent_id``) here. - -Mirrors :mod:`everos.service._session_lock` (and -:class:`everos.core.persistence.markdown.writer.MarkdownWriter`'s -per-path lock pool): one ``asyncio.Lock`` per -``(strategy_name, partition_key)`` pair, **never evicted** — a lock -with pending waiters must outlive any dict entry that points to it, -otherwise GC racing waiters can drop the lock mid-flight (CPython -bpo-28427). The pool grows with the live partition-key set, which in -practice is bounded by the agent / user / cluster counts a single -everos process owns. - -No acquire timeout: an OME strategy run has no upstream client -waiting on it, so timing out a queued caller would only convert -"slow" into a permanent ``dead_letter`` data-loss (`max_retries` -exhaustion). The LLM client owns the per-request timeout -(`component.llm.openai_provider`, default 60s) — that is the layer -that breaks a stuck LLM call, not this one. If a genuinely hung -strategy holds the lock indefinitely it surfaces as a stuck queue -under process-level monitoring; the recovery is a process restart, -not a silent data drop. - -Cross-process safety is out of scope: everos is single-process by -design (see ``CLAUDE.md`` deployment notes); the enterprise edition -layers a distributed coordinator on top. -""" - -from __future__ import annotations - -import asyncio - -_pools: dict[str, dict[str, asyncio.Lock]] = {} - - -def get_partition_lock(strategy_name: str, partition_key: str) -> asyncio.Lock: - """Return the lock for ``(strategy_name, partition_key)``; create on first use. - - ``dict.setdefault`` is atomic under single-threaded asyncio — no - ``await`` runs between the nested ``setdefault`` calls, so the - "check then insert" pair is indivisible. No meta-lock is needed. - - Callers acquire the lock with ``async with``; the lock object is - cached forever (see module docstring on bpo-28427), and the inner - asyncio queue gives FIFO fairness across waiters on the same key. - """ - return _pools.setdefault(strategy_name, {}).setdefault( - partition_key, asyncio.Lock() - ) - - -def _reset_for_tests() -> None: - """Test-only: drop every registered lock pool. - - Used by test fixtures that need a clean lock registry between - cases (no inherited holders, no inherited waiters). - """ - _pools.clear() diff --git a/src/everos/service/__init__.py b/src/everos/service/__init__.py index 2f801cf0f..8c49bb536 100644 --- a/src/everos/service/__init__.py +++ b/src/everos/service/__init__.py @@ -53,12 +53,12 @@ "CategoryOverview", "CreateDocumentResult", "DeleteResult", - "DuplicateDocumentError", "DocumentContext", "DocumentDetail", "DocumentListResult", "DocumentNotFoundError", "DocumentOverviewItem", + "DuplicateDocumentError", "ExtractionEmptyError", "MemorizeResult", "PatchResult", diff --git a/src/everos/service/knowledge.py b/src/everos/service/knowledge.py index a73f9df9d..396c410de 100644 --- a/src/everos/service/knowledge.py +++ b/src/everos/service/knowledge.py @@ -960,7 +960,7 @@ async def _write_original_file( def _get_embedding() -> EmbeddingProvider | None: """Build the embedding client on first call. ``None`` when not configured.""" - global _embedding, _embedding_resolved # noqa: PLW0603 + global _embedding, _embedding_resolved if _embedding_resolved: return _embedding @@ -990,7 +990,7 @@ def _get_embedding() -> EmbeddingProvider | None: def _build_recaller() -> KnowledgeTopicRecaller: """Return the shared :class:`KnowledgeTopicRecaller`, building it on first call.""" - global _recaller, _recaller_resolved # noqa: PLW0603 + global _recaller, _recaller_resolved if _recaller_resolved: return _recaller # type: ignore[return-value] -- guarded by _recaller_resolved @@ -1014,7 +1014,7 @@ def _build_recaller() -> KnowledgeTopicRecaller: def _get_reranker() -> RerankProvider | None: """Build the rerank client on first call. ``None`` when not configured.""" - global _reranker, _reranker_resolved # noqa: PLW0603 + global _reranker, _reranker_resolved if _reranker_resolved: return _reranker diff --git a/src/everos/service/search.py b/src/everos/service/search.py index 1b3129333..e8369d6ec 100644 --- a/src/everos/service/search.py +++ b/src/everos/service/search.py @@ -59,14 +59,10 @@ def _get_embedding() -> EmbeddingProvider | None: from everos.config import load_settings cfg = load_settings().embedding - api_key = cfg.api_key.get_secret_value() if cfg.api_key else "" - if not cfg.model or not api_key or not cfg.base_url: + if not cfg.model or not cfg.api_key or not cfg.api_key.get_secret_value(): logger.warning( "embedding_not_configured", - hint=( - "set [embedding] model / api_key / base_url to enable " - "vector / hybrid search" - ), + hint="set [embedding] model / api_key to enable vector / hybrid search", ) _embedding = None else: @@ -86,14 +82,11 @@ def _get_reranker() -> RerankProvider | None: from everos.config import load_settings cfg = load_settings().rerank - api_key = cfg.api_key.get_secret_value() if cfg.api_key else "" - if not cfg.model or not cfg.base_url or (cfg.provider != "vllm" and not api_key): + has_key = cfg.api_key and cfg.api_key.get_secret_value() + if not cfg.model or not cfg.base_url or not has_key: logger.warning( "rerank_not_configured", - hint=( - "set [rerank] model / api_key / base_url to enable " - "agentic search; api_key is optional only for provider='vllm'" - ), + hint="set [rerank] model / api_key / base_url to enable agentic search", ) _reranker = None else: @@ -113,8 +106,7 @@ def _get_llm_client() -> LLMClient | None: from everos.config import load_settings cfg = load_settings().llm - api_key = cfg.api_key.get_secret_value() if cfg.api_key else "" - if not api_key or not cfg.base_url: + if not cfg.api_key or not cfg.api_key.get_secret_value() or not cfg.base_url: logger.warning( "llm_not_configured", hint="set [llm] api_key / base_url to enable hybrid / agentic search", diff --git a/src/everos/templates/env.template b/src/everos/templates/env.template index 11ada68e3..ad02bee04 100755 --- a/src/everos/templates/env.template +++ b/src/everos/templates/env.template @@ -30,13 +30,6 @@ EVEROS_LLM__MODEL=openai/gpt-4.1-mini EVEROS_LLM__API_KEY= EVEROS_LLM__BASE_URL=https://openrouter.ai/api/v1 -# Aliyun Bailian (DashScope) alternative — one key also covers embedding + -# rerank below, so the same value goes in all three API_KEY slots. -# Uncomment these three lines and comment out the OpenRouter lines above: -# EVEROS_LLM__MODEL=qwen-plus -# EVEROS_LLM__API_KEY= -# EVEROS_LLM__BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 - # ─── Multimodal LLM (independent from [llm]; vision/audio capable) ──── # Separate model for parsing multimodal content items (image / pdf / @@ -66,12 +59,6 @@ EVEROS_MULTIMODAL__BASE_URL=https://openrouter.ai/api/v1 EVEROS_EMBEDDING__MODEL=Qwen/Qwen3-Embedding-4B EVEROS_EMBEDDING__API_KEY= EVEROS_EMBEDDING__BASE_URL=https://api.deepinfra.com/v1/openai - -# Aliyun Bailian (DashScope) alternative. Uncomment these three lines and -# comment out the DeepInfra embedding lines above: -# EVEROS_EMBEDDING__MODEL=text-embedding-v4 -# EVEROS_EMBEDDING__API_KEY= -# EVEROS_EMBEDDING__BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # Runtime knobs — uncomment to override defaults (30s / 3 / 10 / 5): # EVEROS_EMBEDDING__TIMEOUT_SECONDS=30 # EVEROS_EMBEDDING__MAX_RETRIES=3 @@ -81,21 +68,10 @@ EVEROS_EMBEDDING__BASE_URL=https://api.deepinfra.com/v1/openai # ─── Rerank (OpenAI-protocol /rerank) ──────────────── # base_url should point at the rerank endpoint (e.g. .../v1/rerank). -# EVEROS_RERANK__PROVIDER selects the request-shape: "deepinfra" (default), -# "vllm", or "dashscope". Left unset, it is inferred from the base_url host -# (dashscope.aliyuncs.com -> dashscope, *.deepinfra.com -> deepinfra). EVEROS_RERANK__MODEL=Qwen/Qwen3-Reranker-4B EVEROS_RERANK__API_KEY= EVEROS_RERANK__BASE_URL=https://api.deepinfra.com/v1/inference - -# Aliyun Bailian (DashScope) alternative. EverOS currently supports -# DashScope rerank via gte-rerank-v2; provider is auto-inferred from host, -# so EVEROS_RERANK__PROVIDER is optional. Uncomment these three lines and -# comment out the DeepInfra rerank lines above: -# EVEROS_RERANK__MODEL=gte-rerank-v2 -# EVEROS_RERANK__API_KEY= -# EVEROS_RERANK__BASE_URL=https://dashscope.aliyuncs.com # Runtime knobs — uncomment to override defaults (30s / 3 / 10 / 5): # EVEROS_RERANK__TIMEOUT_SECONDS=30 # EVEROS_RERANK__MAX_RETRIES=3 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index f9359c35f..6958fe143 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -35,7 +35,6 @@ import importlib import json from collections.abc import AsyncIterator, Awaitable, Callable -from importlib import resources from pathlib import Path import httpx @@ -125,9 +124,6 @@ async def core_pipeline_runtime( """ monkeypatch.setenv("EVEROS_ROOT", str(tmp_path)) - default_ome = resources.files("everos.config").joinpath("default_ome.toml") - (tmp_path / "ome.toml").write_text(default_ome.read_text(encoding="utf-8")) - from everos.config import load_settings load_settings.cache_clear() diff --git a/tests/test_reflection_e2e.py b/tests/e2e/test_reflection_e2e.py similarity index 99% rename from tests/test_reflection_e2e.py rename to tests/e2e/test_reflection_e2e.py index bb26a5444..882483b57 100644 --- a/tests/test_reflection_e2e.py +++ b/tests/e2e/test_reflection_e2e.py @@ -17,11 +17,11 @@ from pathlib import Path from typing import Any -# Path setup for sibling-module import (test_locomo lives in the same dir). -# load_dotenv() is deferred to main() to avoid module-level side effects. -sys.path.insert(0, str(Path(__file__).resolve().parent)) +# benchmarks/run.py is the benchmark runner; add repo root to sys.path so +# the benchmarks package is importable from any working directory. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from test_locomo import ( # noqa: E402 +from benchmarks.run import ( ANSWER_PROMPT, JUDGE_SYSTEM_PROMPT, JUDGE_USER_PROMPT, diff --git a/tests/e2e/test_search_endpoint_e2e.py b/tests/e2e/test_search_endpoint_e2e.py index 508d35a22..5e74ac436 100644 --- a/tests/e2e/test_search_endpoint_e2e.py +++ b/tests/e2e/test_search_endpoint_e2e.py @@ -352,10 +352,9 @@ async def test_vector_search_returns_episode_hits( ) -> None: """``method=vector`` embeds the query and ranks by cosine. - Seeds atomic_facts alongside episodes because the default - ``vector_strategy = "maxsim_atomic"`` (config/default.toml) walks + Seeds atomic_facts alongside episodes because the MaxSim path walks atomic_fact ANN → max-pool by parent_id → fetch episodes; an - episode-only corpus would return 0 hits under that strategy. + episode-only corpus would return 0 hits. """ await _seed_episodes(_eps_for_owner(search_seed, "caroline")) await _seed_atomic_facts(_facts_for_owner(search_seed, "caroline")) @@ -419,17 +418,15 @@ async def test_agentic_search_returns_episode_hits( # ── Agent owner_type dispatch (separate path: agent_case + agent_skill) ─ -async def _seed_one_agent_corpus( - owner: str = "a1", *, use_real_embeddings: bool = False -) -> None: +async def _seed_one_agent_corpus(owner: str = "a1") -> None: """Single seed used by the parametrized agent dispatch test. One case + one skill sharing surface tokens with the test query ("refactor authentication") so BM25 deterministically hits both - tables. Dense / agentic methods exercise the same rows and opt into - real embeddings so LanceDB's ``nearest_to`` can rank them (zero - vectors are undefined under cosine distance — the dense path returns - 0 hits for them). + tables; dense / agentic methods exercise the same rows. Both rows + are embedded with the real embedder so LanceDB's ``nearest_to`` + can rank them (zero vectors are undefined under cosine distance — + the dense path returns 0 hits for them). """ from everos.service.search import _get_embedding @@ -438,16 +435,16 @@ async def _seed_one_agent_corpus( skill_desc = "refactor authentication middleware reliably" skill_body = "step-by-step approach for auth refactors" - embedder = _get_embedding() if use_real_embeddings else None + embedder = _get_embedding() if embedder is not None: case_vec, skill_vec = await embedder.embed_batch( [f"{case_intent}\n{case_approach}", f"{skill_desc}\n{skill_body}"] ) else: - # Keyword-only default runs offline in CI; live dense variants - # pass real embeddings via ``use_real_embeddings=True``. - case_vec = [1.0, *([0.0] * 1023)] - skill_vec = [1.0, *([0.0] * 1023)] + # No embedder credentials → leave zeros; only keyword assertions + # will pass, vector/hybrid/agentic methods are skipped anyway. + case_vec = [0.0] * 1024 + skill_vec = [0.0] * 1024 await _seed_agent_cases( [ @@ -511,7 +508,7 @@ async def test_search_agent_dispatch_per_method( All methods must enforce the owner_type hard partition: ``episodes`` / ``profiles`` stay empty. """ - await _seed_one_agent_corpus(use_real_embeddings=method != "keyword") + await _seed_one_agent_corpus() resp = await _post( client, @@ -1290,7 +1287,7 @@ async def test_search_filter_error_returns_422( # FastAPI's default ``{"detail": ...}``). The FilterError text # lands in ``error.message``. body = resp.json() - assert body["error"]["code"] == "INVALID_INPUT" + assert body["error"]["code"] == "HTTP_ERROR" assert "this_field_does_not_exist" in body["error"]["message"] @@ -1310,10 +1307,10 @@ async def test_vector_search_with_session_filter( recall query (not bypassed by the dense path). Half the seed gets a target session, half gets another; only target hits may come back. - The default ``vector_strategy = "maxsim_atomic"`` filters atomic_facts - first (then max-pools to episodes), so the per-episode session_id - mutation has to propagate to each fact via its parent memcell id — - otherwise the where clause drops every fact and recall returns 0. + The MaxSim path filters atomic_facts first (then max-pools to + episodes), so the per-episode session_id mutation has to propagate + to each fact via its parent memcell id — otherwise the where clause + drops every fact and recall returns 0. """ base = _eps_for_owner(search_seed, "caroline") facts = _facts_for_owner(search_seed, "caroline") diff --git a/tests/integration/test_cascade_all_kinds_consistency.py b/tests/integration/test_cascade_all_kinds_consistency.py index 2499a6e80..5c56859d7 100644 --- a/tests/integration/test_cascade_all_kinds_consistency.py +++ b/tests/integration/test_cascade_all_kinds_consistency.py @@ -215,12 +215,12 @@ def _ac_item(scope_id: str, j: int): async def _wait_path_done(md_path: str, *, deadline: float = 15.0) -> None: async with asyncio.timeout(deadline): - while True: # noqa: ASYNC110 - polling cascade state + while True: row = await md_change_state_repo.get_by_id(md_path) if row is not None: break await asyncio.sleep(0.05) - while True: # noqa: ASYNC110 - polling cascade state + while True: row = await md_change_state_repo.get_by_id(md_path) if row is not None and row.status in ("done", "failed"): break diff --git a/tests/integration/test_cascade_cli_integration.py b/tests/integration/test_cascade_cli_integration.py index fc57a648f..b8307f82a 100644 --- a/tests/integration/test_cascade_cli_integration.py +++ b/tests/integration/test_cascade_cli_integration.py @@ -60,6 +60,10 @@ async def _dispose_all() -> None: await dispose_engine() +def _strip_ansi(value: str) -> str: + return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", value) + + def test_status_on_empty_queue(cli_runtime: Path) -> None: """``cascade status`` boots the runtime + prints zeros for a fresh DB.""" result = CliRunner().invoke(cascade_mod.app, ["status"]) @@ -121,12 +125,17 @@ def test_sync_with_path_outside_root_errors( other.write_text("# unrelated\n") result = CliRunner().invoke(cascade_mod.app, ["sync", str(other)]) assert result.exit_code != 0 - # Typer.BadParameter surfaces in stderr / mixed output. Rich may wrap - # the error box at different terminal widths, so assert the stable - # semantic fragments instead of their exact adjacency. + # Typer.BadParameter surfaces in stderr / mixed output. The rich + # error box wraps the message at terminal width and pads each line + # with ``│`` (U+2502 box-drawing); so ``not under`` and + # ``memory root`` end up separated by spaces *plus* box characters + # *plus* a newline. ``\s`` doesn't match ``│``, so widen to + # ``[^\w]+`` (anything that isn't an alnum / underscore) — that + # tolerates the rich frame without falsely matching real text + # between the two tokens. output = result.stdout + (result.stderr or "") - assert re.search(r"\bnot under\b", output), output - assert re.search(r"\bmemory root\b", output), output + plain_output = _strip_ansi(output) + assert re.search(r"not under[^\w]+memory root", plain_output), output def test_sync_with_unmatched_path( diff --git a/tests/integration/test_cascade_scenarios.py b/tests/integration/test_cascade_scenarios.py index 70cba274f..a30fb47e4 100644 --- a/tests/integration/test_cascade_scenarios.py +++ b/tests/integration/test_cascade_scenarios.py @@ -127,12 +127,12 @@ async def _wait_path_done(md_path: str, *, deadline: float = 15.0) -> None: last-second re-enqueue (e.g. atomic-replace echo). """ async with asyncio.timeout(deadline): - while True: # noqa: ASYNC110 - polling cascade state + while True: row = await md_change_state_repo.get_by_id(md_path) if row is not None: break await asyncio.sleep(0.05) - while True: # noqa: ASYNC110 - polling cascade state + while True: row = await md_change_state_repo.get_by_id(md_path) if row is not None and row.status in ("done", "failed"): break @@ -400,14 +400,25 @@ async def test_modify_existing_entry_content_reindexes( assert new_text != text mw = MarkdownWriter(memory_root) await mw.write(absolute, new_text) - # The edit reuses md_path; row status flips back to pending then - # to done again. Poll until content_sha256 actually changes. - await asyncio.sleep(0.3) - await _wait_drain() + # The edit reuses md_path; watcher enqueue can lag behind the write, + # so queue-empty is not a sufficient barrier. Poll for the externally + # visible condition this scenario cares about: LanceDB rows reflect the + # rewritten entry bodies. + async with asyncio.timeout(15.0): + while True: + await _wait_drain() + rows_after = await atomic_fact_repo.find_where( + f"md_path = '{md_path}'", limit=10 + ) + if len(rows_after) == 3 and all( + r.content_sha256 != sha_before.get(r.entry_id) + and "EDITED" in r.fact + and "ORIGINAL" not in r.fact + for r in rows_after + ): + break + await asyncio.sleep(0.05) - rows_after = await atomic_fact_repo.find_where( - f"md_path = '{md_path}'", limit=10 - ) assert len(rows_after) == 3 sha_after = {r.entry_id: r.content_sha256 for r in rows_after} fact_after = {r.entry_id: r.fact for r in rows_after} @@ -616,7 +627,7 @@ async def _lance_cleared() -> bool: await asyncio.sleep(0.1) async with asyncio.timeout(5.0): - while True: # noqa: ASYNC110 - polling cascade state + while True: row = await md_change_state_repo.get_by_id(md_path) if row is not None and row.status == "done": break diff --git a/tests/integration/test_ome_strategies_integration.py b/tests/integration/test_ome_strategies_integration.py index 111534c90..5a4cd96ce 100644 --- a/tests/integration/test_ome_strategies_integration.py +++ b/tests/integration/test_ome_strategies_integration.py @@ -217,7 +217,7 @@ async def _setup_system_db_schema(monkeypatch: pytest.MonkeyPatch) -> None: from everos.infra.persistence.sqlite import sqlite_manager - if sqlite_manager._engine is not None: # noqa: SLF001 + if sqlite_manager._engine is not None: await sqlite_manager.dispose_engine() monkeypatch.setattr(sqlite_manager, "_engine", None, raising=False) monkeypatch.setattr(sqlite_manager, "_session_factory", None, raising=False) @@ -231,7 +231,7 @@ async def _teardown_system_db_schema() -> None: the event loop (counterpart of :func:`_setup_system_db_schema`).""" from everos.infra.persistence.sqlite import sqlite_manager - if sqlite_manager._engine is not None: # noqa: SLF001 + if sqlite_manager._engine is not None: await sqlite_manager.dispose_engine() diff --git a/tests/integration/test_reflection_integration.py b/tests/integration/test_reflection_integration.py index afa019a1e..e7ce3c814 100644 --- a/tests/integration/test_reflection_integration.py +++ b/tests/integration/test_reflection_integration.py @@ -182,7 +182,7 @@ async def _setup_sqlite(monkeypatch: pytest.MonkeyPatch) -> None: """Reset the sqlite_manager singleton and create_all tables.""" from everos.infra.persistence.sqlite import sqlite_manager - if sqlite_manager._engine is not None: # noqa: SLF001 + if sqlite_manager._engine is not None: await sqlite_manager.dispose_engine() monkeypatch.setattr(sqlite_manager, "_engine", None, raising=False) monkeypatch.setattr(sqlite_manager, "_session_factory", None, raising=False) @@ -194,7 +194,7 @@ async def _setup_sqlite(monkeypatch: pytest.MonkeyPatch) -> None: async def _teardown_sqlite() -> None: from everos.infra.persistence.sqlite import sqlite_manager - if sqlite_manager._engine is not None: # noqa: SLF001 + if sqlite_manager._engine is not None: await sqlite_manager.dispose_engine() diff --git a/tests/run_locomo_10x3.sh b/tests/run_locomo_10x3.sh deleted file mode 100755 index 7f33f5605..000000000 --- a/tests/run_locomo_10x3.sh +++ /dev/null @@ -1,264 +0,0 @@ -#!/usr/bin/env bash -# Run the LoCoMo benchmark across all 10 conversations × 3 retrieval -# methods (keyword, vector, hybrid). -# -# Wraps tests/run_locomo_batch.sh with the defaults that match the -# everos post-fix benchmark protocol: -# - all 10 LoCoMo conversations (conv 0..9) -# - keyword + vector + hybrid (agentic is skipped — costs 2-3× more -# LLM tokens and the rerank loop hasn't been benchmarked yet) -# - speaker_a partition (the LoCoMo "Plan C" single-owner eval) -# - judge runs = 1 (single-pass LLM judge, no majority vote) -# - top-K 10 -# -# Two ingest modes: -# -# --skip-add (default) reuse the corpus that already lives at -# ~/.everos-report-corpus. Skips the -# ~5 min/conv ingest phase × 10 = ~50 min -# saved. Note: the existing corpus may -# still carry artefacts from the OLD code -# (conv-5 missing episode rows, -# MRAG score=0.0 facts). For a strictly -# clean benchmark of the *fixed* code, -# use --fresh-corpus instead. -# -# --fresh-corpus wipe ~/.everos-report-corpus, restart -# the server, and re-ingest every conv -# with the current bug-fixed cascade. -# Adds ~50 min to the run. -# -# Server must already be running on :8000 with the current code loaded -# (i.e. the OR + optimize fixes). Health check confirmed before launch. -# -# Output structure: -# -# benchmark_results/run__10x3/ -# ├── conv0.json ... conv9.json ← per-conv final results -# ├── conv0_checkpoints/ ... ← phase-level snapshots -# └── SUMMARY.md ← cross-conv accuracy table - -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" -REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." &> /dev/null && pwd)" - -# ── Defaults ────────────────────────────────────────────────────────── -BASE_URL="${BASE_URL:-http://localhost:8000}" -DATA_PATH="${DATA_PATH:-data/locomo10.json}" -MEMORY_ROOT="${EVEROS_ROOT:-$HOME/.everos-report-corpus}" -MODE="skip-add" # default; toggle via --fresh-corpus -TS="$(date +%Y%m%d_%H%M%S)" -OUTPUT_ROOT="$REPO_ROOT/benchmark_results/run_${TS}_10x3" - -# ── Parse args ──────────────────────────────────────────────────────── -while [[ $# -gt 0 ]]; do - case "$1" in - --skip-add) MODE="skip-add"; shift ;; - --fresh-corpus) MODE="fresh"; shift ;; - --base-url) BASE_URL="$2"; shift 2 ;; - --memory-root) MEMORY_ROOT="$2"; shift 2 ;; - --output-root) OUTPUT_ROOT="$2"; shift 2 ;; - -h|--help) - grep -E "^# " "$0" | sed 's/^# //;s/^#//' - exit 0 - ;; - *) - echo "unknown arg: $1" >&2 - exit 1 - ;; - esac -done - -# ── Preflight ───────────────────────────────────────────────────────── -echo "═════════════════════════════════════════════════════════════════" -echo " LoCoMo 10 × 3 benchmark" -echo "═════════════════════════════════════════════════════════════════" -echo " mode: $MODE" -echo " base_url: $BASE_URL" -echo " memory_root: $MEMORY_ROOT" -echo " output: $OUTPUT_ROOT" -echo - -# 1. Server up? -if ! curl -fsS -o /dev/null "$BASE_URL/health" 2>/dev/null; then - echo "❌ server at $BASE_URL is not responding" - echo " start with: EVEROS_ROOT=$MEMORY_ROOT PYTHONPATH=src \\" - echo " python -m everos.entrypoints.cli.main server start --port 8000" - exit 1 -fi -echo "✓ server healthy" - -# 2. LLM env (test_locomo.py reads bare LLM_* — bridge from EVEROS_LLM__*) -if [[ -z "${LLM_API_KEY:-}" ]] || [[ -z "${LLM_BASE_URL:-}" ]] || [[ -z "${LLM_MODEL:-}" ]]; then - if [[ -f "$REPO_ROOT/.env" ]]; then - set -a - # shellcheck source=/dev/null - source <(grep -E "^EVEROS_LLM__" "$REPO_ROOT/.env" | sed 's/EVEROS_LLM__/LLM_/') - set +a - fi -fi -if [[ -z "${LLM_API_KEY:-}" ]]; then - echo "❌ LLM_API_KEY not set (and .env has no EVEROS_LLM__API_KEY to bridge from)" - exit 1 -fi -echo "✓ LLM credentials: model=$LLM_MODEL @ $LLM_BASE_URL" -echo - -# 3. Fresh corpus mode → wipe + restart server -if [[ "$MODE" == "fresh" ]]; then - echo "═════════════════════════════════════════════════════════════════" - echo " --fresh-corpus: wiping $MEMORY_ROOT and restarting server" - echo "═════════════════════════════════════════════════════════════════" - - # Find and kill existing server (best-effort) - pids="$(pgrep -f "everos.entrypoints.cli.main server" || true)" - if [[ -n "$pids" ]]; then - echo " stopping server pid(s): $pids" - # shellcheck disable=SC2086 - kill $pids - sleep 3 - fi - - rm -rf "$MEMORY_ROOT" - mkdir -p "$MEMORY_ROOT" - - # Restart in background; the server picks up the empty memory root. - echo " starting fresh server..." - ( - cd "$REPO_ROOT" - EVEROS_ROOT="$MEMORY_ROOT" \ - PYTHONPATH=src \ - nohup python -m everos.entrypoints.cli.main server start --port 8000 \ - > /tmp/everos-server-${TS}.log 2>&1 & - echo " server pid=$!" - ) - - # Wait for lifespan ready - for i in $(seq 1 60); do - if curl -fsS -o /dev/null "$BASE_URL/health" 2>/dev/null; then - echo " server ready after ${i}s" - break - fi - sleep 1 - done - if ! curl -fsS -o /dev/null "$BASE_URL/health" 2>/dev/null; then - echo "❌ server failed to come up; see /tmp/everos-server-${TS}.log" - exit 1 - fi - echo -fi - -# ── Build the batch invocation ──────────────────────────────────────── -BATCH_ARGS=( - --conv-indices 0-9 - --methods keyword,vector,hybrid - --base-url "$BASE_URL" - --top-k 10 - --eval-owner speaker_a - --judge-runs 1 - --output-root "$OUTPUT_ROOT" -) -[[ "$MODE" == "skip-add" ]] && BATCH_ARGS+=( --skip-add ) - -echo "═════════════════════════════════════════════════════════════════" -echo " Launching: tests/run_locomo_batch.sh ${BATCH_ARGS[*]}" -echo "═════════════════════════════════════════════════════════════════" -echo - -cd "$REPO_ROOT" -bash tests/run_locomo_batch.sh "${BATCH_ARGS[@]}" - -# ── Summary markdown ────────────────────────────────────────────────── -echo -echo "═════════════════════════════════════════════════════════════════" -echo " Rendering SUMMARY.md" -echo "═════════════════════════════════════════════════════════════════" - -python - </conv.json`` so reports never collide. -# An aggregate accuracy table is printed at the end. -# -# Examples -# ──────── -# # all 10 convs, hybrid only: -# bash tests/run_locomo_batch.sh --conv-indices 0-9 --methods hybrid -# -# # 3 specific convs, two methods, skip the ~5min Add phase (corpus already loaded): -# bash tests/run_locomo_batch.sh \ -# --conv-indices 0,3,7 --methods keyword,hybrid --skip-add -# -# # one conv, all 4 methods comparison: -# bash tests/run_locomo_batch.sh --conv-indices 0 --methods keyword,vector,hybrid,agentic - -set -euo pipefail - -# ── Defaults (override via flags) ───────────────────────────────────── -BASE_URL="${BASE_URL:-http://localhost:8000}" -DATA_PATH="${DATA_PATH:-data/locomo10.json}" -CONV_INDICES="${CONV_INDICES:-0}" -METHODS="${METHODS:-hybrid}" -TOP_K="${TOP_K:-10}" -EVAL_OWNER="${EVAL_OWNER:-speaker_a}" -JUDGE_RUNS="${JUDGE_RUNS:-1}" -SKIP_ADD="false" -OUTPUT_ROOT="" -CONCURRENCY="${CONCURRENCY:-1}" -# Default to polling cascade pending==0 (not fixed sleep). Falls back to -# ~/.everos to match the server's default data root; override via env or -# EVEROS_ROOT (which the server consumes). post-flush-wait becomes -# the MAX wait when corpus-path is set. -CORPUS_PATH="${CORPUS_PATH:-${EVEROS_ROOT:-$HOME/.everos}}" -POST_FLUSH_WAIT="${POST_FLUSH_WAIT:-600}" -EXTRA_ARGS=() - -usage() { - cat < conv list — "0,1,2" | "0-9" | "all" (default: $CONV_INDICES) - --methods comma-separated, e.g. "keyword,hybrid" (default: $METHODS) - --base-url everos server (default: $BASE_URL) - --data-path LoCoMo dataset path (default: $DATA_PATH) - --top-k per-question recall depth (default: $TOP_K) - --eval-owner speaker_a | speaker_b (default: $EVAL_OWNER) - --judge-runs LLM judge majority-vote runs (default: $JUDGE_RUNS) - --skip-add reuse existing corpus, skip ingest - --output-root parent dir for results - (default: benchmark_results/run_) - --concurrency run up to N convs in parallel (default: 1 = serial) - per-conv stdout/stderr is redirected to - \$OUTPUT_ROOT/conv.log so streams don't interleave - -h | --help show this help - -- everything after is forwarded to test_locomo.py - -Any positional or unknown arg goes through to test_locomo.py untouched. -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --conv-indices) CONV_INDICES="$2"; shift 2 ;; - --methods) METHODS="$2"; shift 2 ;; - --base-url) BASE_URL="$2"; shift 2 ;; - --data-path) DATA_PATH="$2"; shift 2 ;; - --top-k) TOP_K="$2"; shift 2 ;; - --eval-owner) EVAL_OWNER="$2"; shift 2 ;; - --judge-runs) JUDGE_RUNS="$2"; shift 2 ;; - --skip-add) SKIP_ADD="true"; shift ;; - --output-root) OUTPUT_ROOT="$2"; shift 2 ;; - --concurrency) CONCURRENCY="$2"; shift 2 ;; - -h|--help) usage; exit 0 ;; - --) shift; EXTRA_ARGS+=("$@"); break ;; - *) EXTRA_ARGS+=("$1"); shift ;; - esac -done - -# ── Expand conv-indices spec ────────────────────────────────────────── -expand_indices() { - local spec="$1" - if [[ "$spec" == "all" ]]; then - echo "0 1 2 3 4 5 6 7 8 9" - return - fi - if [[ "$spec" =~ ^([0-9]+)-([0-9]+)$ ]]; then - seq "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" - return - fi - echo "$spec" | tr ',' ' ' -} - -INDICES=$(expand_indices "$CONV_INDICES") -TS="$(date +%Y%m%d_%H%M%S)" -OUTPUT_ROOT="${OUTPUT_ROOT:-benchmark_results/run_${TS}}" -mkdir -p "$OUTPUT_ROOT" - -# ── Plan banner ─────────────────────────────────────────────────────── -echo "═════════════════════════════════════════════════════════════════" -echo " LoCoMo batch run" -echo "═════════════════════════════════════════════════════════════════" -printf " base_url : %s\n" "$BASE_URL" -printf " conv_indices : %s\n" "$(echo "$INDICES" | tr '\n' ' ')" -printf " methods : %s\n" "$METHODS" -printf " top_k : %s\n" "$TOP_K" -printf " eval_owner : %s\n" "$EVAL_OWNER" -printf " judge_runs : %s\n" "$JUDGE_RUNS" -printf " skip_add : %s\n" "$SKIP_ADD" -printf " concurrency : %s\n" "$CONCURRENCY" -printf " output_root : %s\n" "$OUTPUT_ROOT" -[[ ${#EXTRA_ARGS[@]} -gt 0 ]] && printf " forwarded args : %s\n" "${EXTRA_ARGS[*]}" -echo - -# ── Build per-conv command and launch ──────────────────────────────── -# -# bash 3.2 (macOS default) lacks namerefs (`local -n`) and `wait -n`, so -# build_cmd populates a global array CMD and the parallel scheduler -# uses a poll-loop with `kill -0` instead of `wait -n`. -build_cmd() { - local _ci="$1" - CMD=( - PYTHONPATH=src - python tests/test_locomo.py - --base-url "$BASE_URL" - --data-path "$DATA_PATH" - --conv-index "$_ci" - --methods "$METHODS" - --top-k "$TOP_K" - --eval-owner "$EVAL_OWNER" - --judge-runs "$JUDGE_RUNS" - --output "$OUTPUT_ROOT/conv${_ci}.json" - --checkpoint-dir "$OUTPUT_ROOT/conv${_ci}_checkpoints" - --corpus-path "$CORPUS_PATH" - --post-flush-wait "$POST_FLUSH_WAIT" - --quiet - ) - [[ "$SKIP_ADD" == "true" ]] && CMD+=( --skip-add ) - [[ ${#EXTRA_ARGS[@]} -gt 0 ]] && CMD+=( "${EXTRA_ARGS[@]}" ) - # Final no-op: the trailing [[ ]] above can be false (e.g. no extra - # args), which would make the function's exit status non-zero and - # trip `set -e` in the caller. Explicit success keeps the contract. - return 0 -} - -FAILED=() - -if [[ "$CONCURRENCY" -le 1 ]]; then - # ── Serial path (legacy behaviour) ────────────────────────────────── - for CI in $INDICES; do - echo "═════════════════════════════════════════════════════════════════" - echo " conv $CI → $OUTPUT_ROOT/conv${CI}.json" - echo "═════════════════════════════════════════════════════════════════" - build_cmd "$CI" - set +e - env "${CMD[@]}" - rc=$? - set -e - if [[ $rc -ne 0 ]]; then - FAILED+=("conv${CI}") - fi - done -else - # ── Parallel path: job pool of $CONCURRENCY workers ───────────────── - # - # Each conv runs in its own python process, streaming to a per-conv - # log file (conv.log) so interleaved stdout doesn't turn into - # confetti. Status is collected via `wait $pid`; one conv's failure - # does not abort the rest. - echo "─────────────────────────────────────────────────────────────────" - echo " Parallel mode: up to $CONCURRENCY convs concurrent" - echo " Per-conv logs: $OUTPUT_ROOT/conv.log" - echo "─────────────────────────────────────────────────────────────────" - - # Parallel arrays (no associative arrays in bash 3.2). - RUN_PIDS=() - RUN_CIS=() - - # Wait for *any* worker to exit, reap it, prune the slot, record - # failures. Polls because `wait -n` is bash 4.3+. - reap_one() { - while true; do - local idx - for idx in "${!RUN_PIDS[@]}"; do - local pid="${RUN_PIDS[$idx]}" - if ! kill -0 "$pid" 2>/dev/null; then - set +e - wait "$pid" - local rc=$? - set -e - local ci="${RUN_CIS[$idx]}" - if [[ $rc -eq 0 ]]; then - echo " ✓ conv${ci} done (pid $pid)" - else - echo " ✗ conv${ci} failed (pid $pid, status $rc) — see $OUTPUT_ROOT/conv${ci}.log" - FAILED+=("conv${ci}") - fi - unset 'RUN_PIDS[idx]' - unset 'RUN_CIS[idx]' - # Re-pack arrays so ${#RUN_PIDS[@]} stays accurate. - RUN_PIDS=("${RUN_PIDS[@]}") - RUN_CIS=("${RUN_CIS[@]}") - return 0 - fi - done - sleep 2 - done - } - - for CI in $INDICES; do - build_cmd "$CI" - LOG="$OUTPUT_ROOT/conv${CI}.log" - echo " → launching conv${CI} (log: $LOG)" - env "${CMD[@]}" > "$LOG" 2>&1 & - pid=$! - RUN_PIDS+=("$pid") - RUN_CIS+=("$CI") - - if [[ ${#RUN_PIDS[@]} -ge $CONCURRENCY ]]; then - reap_one - fi - done - - # Drain the remaining workers. - while [[ ${#RUN_PIDS[@]} -gt 0 ]]; do - reap_one - done -fi - -if [[ ${#FAILED[@]} -gt 0 ]]; then - echo - echo "⚠ ${#FAILED[@]} conv(s) failed: ${FAILED[*]}" -fi - -# ── Aggregate summary ───────────────────────────────────────────────── -echo -echo "═════════════════════════════════════════════════════════════════" -echo " Aggregate accuracy" -echo "═════════════════════════════════════════════════════════════════" -python - <10}" for m in methods_seen) -print(header) -print("─" * len(header)) - -for p in files: - d = json.load(open(p)) - label = p.stem - cells = [] - for m in methods_seen: - mr = d["methods"].get(m) - if mr is None: - cells.append(f"{'—':>10}") - else: - raw = mr["summary"]["accuracy"] - acc = float(str(raw).rstrip("%")) if isinstance(raw, str) else float(raw) * 100 - cells.append(f"{acc:>9.1f}%") - print(f"{label:<{w}} " + " ".join(cells)) - -print() -print(f" detailed JSONs: {root}/conv*.json") -print(f" phase checkpoints: {root}/conv*_checkpoints/") -EOF diff --git a/tests/run_locomo_full.sh b/tests/run_locomo_full.sh deleted file mode 100644 index b32dd6f0e..000000000 --- a/tests/run_locomo_full.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -# ────────────────────────────────────────────────────────────────────── -# Full LoCoMo Benchmark Runner -# -# Usage: -# bash tests/run_locomo_full.sh # default: 5 runs × hybrid,agentic -# bash tests/run_locomo_full.sh --runs 3 # 3 runs -# bash tests/run_locomo_full.sh --methods hybrid # hybrid only -# bash tests/run_locomo_full.sh --skip-add # reuse existing data -# ────────────────────────────────────────────────────────────────────── -set -euo pipefail - -RUNS="${RUNS:-5}" -METHODS="${METHODS:-hybrid,agentic}" -SKIP_ADD="" -DATA_PATH="data/locomo10.json" -CONVS=10 -POST_FLUSH_WAIT=180 -JUDGE_MODEL="gpt-4o-mini" -JUDGE_RUNS=5 -TOP_K=10 -OUTPUT_DIR="benchmark_results" -SEARCH_CONCURRENCY=1 - -# Parse args -while [[ $# -gt 0 ]]; do - case "$1" in - --runs) RUNS="$2"; shift 2 ;; - --methods) METHODS="$2"; shift 2 ;; - --skip-add) SKIP_ADD="--skip-add"; shift ;; - --output-dir) OUTPUT_DIR="$2"; shift 2 ;; - --post-flush-wait) POST_FLUSH_WAIT="$2"; shift 2 ;; - *) echo "Unknown arg: $1"; exit 1 ;; - esac -done - -TS=$(date +%Y%m%d_%H%M%S) -RUN_DIR="${OUTPUT_DIR}/run_${TS}" -mkdir -p "$RUN_DIR" - -echo "════════════════════════════════════════════════════════════════" -echo " LoCoMo Full Benchmark" -echo " Runs: $RUNS | Methods: $METHODS | Convs: $CONVS" -echo " Judge: $JUDGE_MODEL (${JUDGE_RUNS} runs/question)" -echo " Output: $RUN_DIR" -echo "════════════════════════════════════════════════════════════════" - -# Phase 1: Add all 10 conversations (once) -if [[ -z "$SKIP_ADD" ]]; then - echo "" - echo "──── Phase 1: Loading all $CONVS conversations ────" - for conv in $(seq 0 $((CONVS - 1))); do - echo " Loading conv $conv..." - uv run python tests/test_locomo.py \ - --conv-index "$conv" \ - --methods hybrid \ - --data-path "$DATA_PATH" \ - --post-flush-wait "$POST_FLUSH_WAIT" \ - --judge-model "$JUDGE_MODEL" \ - --judge-runs 1 \ - --top-k 1 \ - --quiet \ - --search-concurrency 1 \ - --checkpoint-dir "$RUN_DIR/load_conv${conv}" \ - 2>&1 | tail -5 - echo " conv $conv loaded." - done - echo " All conversations loaded." - SKIP_ADD="--skip-add" -fi - -# Phase 2: Run benchmark (search + answer + judge) -IFS=',' read -ra METHOD_LIST <<< "$METHODS" - -for run_idx in $(seq 1 "$RUNS"); do - for method in "${METHOD_LIST[@]}"; do - echo "" - echo "══════════════════════════════════════════════════════════" - echo " Run $run_idx/$RUNS — method=$method" - echo "══════════════════════════════════════════════════════════" - - run_out="$RUN_DIR/${method}_run${run_idx}" - mkdir -p "$run_out" - summary_file="$run_out/summary.json" - - all_correct=0 - all_total=0 - - for conv in $(seq 0 $((CONVS - 1))); do - conv_out="$run_out/conv${conv}" - result_file="$conv_out/${method}_results.json" - - echo " conv $conv ($method, run $run_idx)..." - uv run python tests/test_locomo.py \ - --conv-index "$conv" \ - --methods "$method" \ - --data-path "$DATA_PATH" \ - --skip-add \ - --judge-model "$JUDGE_MODEL" \ - --judge-runs "$JUDGE_RUNS" \ - --top-k "$TOP_K" \ - --quiet \ - --search-concurrency "$SEARCH_CONCURRENCY" \ - --checkpoint-dir "$conv_out" \ - --output "$result_file" \ - 2>&1 | grep -E "Overall:|Done:" | head -3 - - # Extract accuracy from result JSON - if [[ -f "$result_file" ]]; then - conv_correct=$(python3 -c " -import json, sys -d = json.load(open('$result_file')) -s = d.get('methods', {}).get('$method', {}).get('summary', {}) -print(s.get('correct', 0)) -" 2>/dev/null || echo 0) - conv_total=$(python3 -c " -import json, sys -d = json.load(open('$result_file')) -s = d.get('methods', {}).get('$method', {}).get('summary', {}) -print(s.get('total', 0)) -" 2>/dev/null || echo 0) - all_correct=$((all_correct + conv_correct)) - all_total=$((all_total + conv_total)) - fi - done - - # Write run summary - if [[ $all_total -gt 0 ]]; then - accuracy=$(python3 -c "print(f'{$all_correct / $all_total * 100:.1f}')") - else - accuracy="0.0" - fi - echo "{\"method\": \"$method\", \"run\": $run_idx, \"correct\": $all_correct, \"total\": $all_total, \"accuracy\": $accuracy}" > "$summary_file" - echo " ── Run $run_idx $method: $all_correct / $all_total ($accuracy%) ──" - done -done - -# Final summary -echo "" -echo "════════════════════════════════════════════════════════════════" -echo " Final Results" -echo "════════════════════════════════════════════════════════════════" -for method in "${METHOD_LIST[@]}"; do - echo " $method:" - for run_idx in $(seq 1 "$RUNS"); do - summary="$RUN_DIR/${method}_run${run_idx}/summary.json" - if [[ -f "$summary" ]]; then - python3 -c " -import json -d = json.load(open('$summary')) -print(f\" Run {d['run']}: {d['correct']}/{d['total']} ({d['accuracy']}%)\") -" - fi - done -done -echo "════════════════════════════════════════════════════════════════" diff --git a/tests/test_locomo.py b/tests/test_locomo.py deleted file mode 100644 index 98d9654d2..000000000 --- a/tests/test_locomo.py +++ /dev/null @@ -1,1714 +0,0 @@ -""" -EverOS E2E Benchmark — LoCoMo conversation evaluation. - -Self-contained script that exercises the full everos HTTP pipeline: - Add (per LoCoMo session) -> Flush -> Search -> Answer (LLM) -> Evaluate (LLM Judge) - -LoCoMo ↔ everos mapping (Plan C: single-owner evaluation): -- LoCoMo conversation N → owner_id = speaker_a.lower() (or speaker_b - via --eval-owner). Query a single speaker's - partition; everos's pipeline fan-outs the - same episode narrative to every user sender - (see user_memory.py:95-117), so single-owner - retrieval is informationally equivalent to - multi-owner merged. -- LoCoMo session_N (sitting) → everos session_id = f"locomo_conv{N}_s{idx}" - with a /memory/flush after each session. -- Each LoCoMo message → MessageItemDTO with real sender_id - (speaker.lower()), preserving speaker - attribution in the storage / extraction. - -Usage: - python tests/test_locomo.py --methods hybrid --quiet - python tests/test_locomo.py --methods keyword,hybrid,agentic --output results.json - python tests/test_locomo.py --skip-add --methods hybrid # reuse loaded data -""" - -import argparse -import concurrent.futures -import json -import os -import re -import statistics -import sys -import threading -import time -from datetime import UTC, datetime -from typing import Any - -import openai -import requests -from dotenv import load_dotenv - -try: - from tqdm import tqdm as _tqdm -except ImportError: # progress bar is a nice-to-have, never a hard dep - _tqdm = None - - -def _progress(iterable, *, desc: str, total: int, quiet: bool): - """Wrap a loop iterable in a tqdm bar, but only in quiet mode. - - Verbose mode already prints a per-item line, so a bar there would just - fight those prints. Quiet mode prints nothing per item, which is exactly - when the user is left staring at a frozen screen — so that is where the - bar earns its keep. Falls back to the bare iterable if tqdm is missing. - """ - if not quiet or _tqdm is None: - return iterable - return _tqdm(iterable, desc=desc, total=total, unit="item", dynamic_ncols=True) - - -# ============================================================================= -# Inline prompts (originally from everosos-opensource evaluation/) -# ============================================================================= - -ANSWER_PROMPT = """ -You are an intelligent memory assistant tasked with retrieving accurate information from episodic memories. - -# CONTEXT: -You have access to episodic memories from conversations between two speakers. These memories contain -timestamped information that may be relevant to answering the question. - -# INSTRUCTIONS: -Your goal is to synthesize information from all relevant memories to provide a comprehensive and accurate answer. -You MUST follow a structured Chain-of-Thought process to ensure no details are missed. -Actively look for connections between people, places, and events to build a complete picture. Synthesize information from different memories to answer the user's question. -It is CRITICAL that you move beyond simple fact extraction and perform logical inference. When the evidence strongly suggests a connection, you must state that connection. Do not dismiss reasonable inferences as "speculation." Your task is to provide the most complete answer supported by the available evidence. - -# CRITICAL REQUIREMENTS: -1. NEVER omit specific names - use "Amy's colleague Rob" not "a colleague" -2. ALWAYS include exact numbers, amounts, prices, percentages, dates, times -3. PRESERVE frequencies exactly - "every Tuesday and Thursday" not "twice a week" -4. MAINTAIN all proper nouns and entities as they appear -5. EXPLICITLY state confidence levels for inferences (High/Medium/Low) - -# RESPONSE FORMAT (You MUST follow this structure): - -## STEP 1: RELEVANT MEMORIES EXTRACTION -[List each memory that relates to the question, with its timestamp] -- Memory [ID]: [timestamp] - [content snippet] - -## STEP 2: KEY INFORMATION IDENTIFICATION -[Extract ALL specific details from the memories] -- Names mentioned: [list all person names, place names, company names] -- Numbers/Quantities: [list all amounts, prices, percentages] -- Dates/Times: [list all temporal information] -- Frequencies: [list any recurring patterns] -- Other entities: [list brands, products, etc.] - -## STEP 3: CROSS-MEMORY LINKING & INFERENCE -[Identify entities that appear in multiple memories and link related information. Make reasonable inferences when entities are strongly connected.] -- Shared entities: [list people, places, events mentioned across different memories] -- Connections found: [e.g., "Memory 1 mentions A moved from hometown -> Memory 2 mentions A's hometown is LA -> Therefore A moved from LA"] -- Inferences: [Connect the dots. Label confidence: (Confidence: High/Medium/Low)] - -## STEP 4: TIME REFERENCE CALCULATION -[If applicable, convert relative time references using the timestamps] -- Original reference: [e.g., "last year" from May 2022] -- Calculation: [Show logic] -- Actual time: [e.g., "2021"] - -## STEP 5: CONTRADICTION & GAP ANALYSIS -[Check for conflicts and missing details] -- Conflicting information: [describe conflicts and resolution strategy] -- Missing information: [explicitly state what details are requested but missing from context] - -## STEP 6: DETAIL VERIFICATION CHECKLIST -- [ ] All person names included? -- [ ] All locations included? -- [ ] All numbers exact? -- [ ] All frequencies specific? -- [ ] All dates/times precise? -- [ ] All proper nouns preserved? - -## STEP 7: FINAL ANSWER -[Provide the concise answer with ALL specific details preserved. Do not include the internal checklist in this section, just the final synthesized answer.] - ---- - -{context} - -Question: {question} - -Now, follow the Chain-of-Thought process above to answer the question: -""" - -JUDGE_SYSTEM_PROMPT = "You are an expert grader that determines if answers to questions match a gold standard answer" - -JUDGE_USER_PROMPT = """Your task is to label an answer to a question as 'CORRECT' or 'WRONG'. You will be given the following data: - (1) a question (posed by one user to another user), - (2) a 'gold' (ground truth) answer, - (3) a generated answer -which you will score as CORRECT/WRONG. - -The point of the question is to ask about something one user should know about the other user based on their prior conversations. -The gold answer will usually be a concise and short answer that includes the referenced topic, for example: -Question: Do you remember what I got the last time I went to Hawaii? -Gold answer: A shell necklace -The generated answer might be much longer, but you should be generous with your grading - as long as it touches on the same topic as the gold answer, it should be counted as CORRECT. - -For time related questions, the gold answer will be a specific date, month, year, etc. The generated answer might be much longer or use relative time references (like "last Tuesday" or "next month"), but you should be generous with your grading - as long as it refers to the same date or time period as the gold answer, it should be counted as CORRECT. Even if the format differs (e.g., "May 7th" vs "7 May"), consider it CORRECT if it's the same date. - -Now it's time for the real question: -Question: {question} -Gold answer: {golden_answer} -Generated answer: {generated_answer} - -First, provide a short (one sentence) explanation of your reasoning, then finish with CORRECT or WRONG. -Do NOT include both CORRECT and WRONG in your response, or it will break the evaluation script. - -Just return the label CORRECT or WRONG in a json format with the key as "label". -""" - -# ============================================================================= -# Category labels -# ============================================================================= - -CATEGORY_NAMES: dict[int, str] = { - 1: "single-hop", - 2: "multi-hop", - 3: "open-domain", - 4: "temporal", -} - -# ============================================================================= -# Minimal HTTP client for everos (single-tenant, no auth headers) -# ============================================================================= - - -class EverosClient: - """Minimal HTTP client for everos's /api/v1/memory/* endpoints.""" - - def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 300): - self.base_url = base_url.rstrip("/") - self.timeout = timeout - - def post( - self, path: str, data: dict[str, Any], quiet: bool = False - ) -> tuple[int, dict]: - full_url = f"{self.base_url}{path}" - if not quiet: - print(f"\n📍 URL: POST {full_url}") - print(f"📤 Request Data:\n{json.dumps(data, indent=2, ensure_ascii=False)}") - try: - resp = requests.post( - full_url, - json=data, - headers={"Content-Type": "application/json"}, - timeout=(10, self.timeout), - ) - except requests.RequestException as e: - if not quiet: - print(f"📥 Request Error: {e}") - return -1, {"error": str(e)} - if not quiet: - print(f"\n📥 Response Status Code: {resp.status_code}") - try: - body = resp.json() - if not quiet: - print( - f"📥 Response Data:\n{json.dumps(body, indent=2, ensure_ascii=False)}" - ) - return resp.status_code, body - except Exception: - if not quiet: - print(f"📥 Raw Response: {resp.text[:500]}") - return resp.status_code, {} - - -def print_section(title: str): - print(f"\n{'=' * 72}") - print(f" {title}") - print(f"{'=' * 72}") - - -# ============================================================================= -# LLM client pool — round-robin across multiple API keys with 429 failover -# ============================================================================= - - -def _split_keys(s: str) -> list[str]: - """Split a comma-separated key string into a list of stripped non-empty keys.""" - return [k.strip() for k in s.split(",") if k.strip()] - - -class _PoolCompletions: - def __init__(self, pool: "LLMClientPool"): - self._pool = pool - - def create(self, **kwargs: Any) -> Any: - return self._pool._create_with_failover(**kwargs) - - -class _PoolChat: - def __init__(self, pool: "LLMClientPool"): - self.completions = _PoolCompletions(pool) - - -class LLMClientPool: - """Round-robin pool of openai.OpenAI clients with RateLimitError failover. - - Duck-types openai.OpenAI: callers may use ``pool.chat.completions.create(...)`` - transparently. On RateLimitError, the next key in the pool is tried; after - all keys are exhausted, the last error is re-raised. Other errors propagate - immediately (they're not "this key is throttled" signals). - - When ``base_url`` points to OpenRouter, the pool injects - ``extra_body={"provider": {"only": [...]}}`` on every request so the - downstream provider is fixed. OpenRouter otherwise routes freely across - providers (OpenAI, Azure, Fireworks, ...), which on a 1.5k-question batch - eventually lands on a region-restricted Azure instance and 403s every - later request. The allow-list defaults to ``["openai"]`` and can be - overridden via the ``OPENROUTER_PROVIDER_ONLY`` env var (comma-separated, - e.g. ``openai,fireworks``). - """ - - def __init__(self, api_keys: list[str], base_url: str, **kwargs: Any): - if not api_keys: - raise ValueError("LLMClientPool: at least one API key required") - self._clients = [ - openai.OpenAI(api_key=k, base_url=base_url, **kwargs) for k in api_keys - ] - self._idx = 0 - self._lock = threading.Lock() - self.key_count = len(self._clients) - self.chat = _PoolChat(self) - self._provider_constraint = self._resolve_provider_constraint(base_url) - - @staticmethod - def _resolve_provider_constraint(base_url: str) -> dict[str, Any] | None: - """Resolve the OpenRouter ``provider`` extra-body block (or None).""" - if "openrouter" not in (base_url or "").lower(): - return None - raw = os.getenv("OPENROUTER_PROVIDER_ONLY", "openai").strip() - if not raw or raw.lower() == "any": - return None - only = [p.strip() for p in raw.split(",") if p.strip()] - return {"only": only, "allow_fallbacks": False} - - def _next_client(self) -> openai.OpenAI: - with self._lock: - c = self._clients[self._idx] - self._idx = (self._idx + 1) % len(self._clients) - return c - - def _create_with_failover(self, **kwargs: Any) -> Any: - if self._provider_constraint is not None: - extra = dict(kwargs.get("extra_body") or {}) - extra.setdefault("provider", self._provider_constraint) - kwargs["extra_body"] = extra - last_err: Exception | None = None - for _ in range(len(self._clients)): - client = self._next_client() - try: - return client.chat.completions.create(**kwargs) - except openai.RateLimitError as e: - last_err = e - continue - assert last_err is not None - raise last_err - - -def _parallel_map( - items: list, - worker, - *, - desc: str, - total: int, - quiet: bool, - concurrency: int, -) -> list: - """Run ``worker(i, item)`` over *items* concurrently; preserve input order. - - Quiet mode drives a tqdm progress bar via ``as_completed``; verbose mode - lets workers stay silent to avoid interleaved output. Falls back to serial - execution when *concurrency* <= 1. - - Worker exceptions are caught per-item: the exception object is stored in - ``results[i]`` and re-raised by callers as needed. This prevents one bad - LLM call from aborting the entire batch. - - Args: - items: Input list to process. - worker: Callable ``(i: int, item: Any) -> Any``. - desc: Label shown in the tqdm bar. - total: Expected number of items (for the bar). - quiet: When True and tqdm is available, show a progress bar. - concurrency: Thread-pool size; <= 1 means serial. - - Returns: - List of worker results in the same order as *items*. - """ - results: list = [None] * len(items) - - if concurrency <= 1: - for i, item in enumerate(items): - results[i] = worker(i, item) - return results - - with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool: - future_to_idx: dict[concurrent.futures.Future, int] = { - pool.submit(worker, i, item): i for i, item in enumerate(items) - } - if quiet and _tqdm is not None: - completed_iter = _tqdm( - concurrent.futures.as_completed(future_to_idx), - total=total, - desc=desc, - unit="item", - dynamic_ncols=True, - ) - else: - completed_iter = concurrent.futures.as_completed(future_to_idx) - - for fut in completed_iter: - idx = future_to_idx[fut] - try: - results[idx] = fut.result() - except Exception as exc: # noqa: BLE001 - results[idx] = exc - - return results - - -def _wait_cascade_drain( - corpus_path: str, max_wait_s: int, poll_interval_s: float = 3.0 -) -> None: - """Poll everos cascade queue in /.index/sqlite/system.db. - - Returns as soon as ``md_change_state`` has no row in - ``status IN ('pending', 'processing')``, or after ``max_wait_s`` - seconds, whichever comes first. Falls back to a fixed sleep if the - sqlite file is missing (e.g. corpus_path wrong / server not yet - written its system.db). - """ - import sqlite3 - from pathlib import Path - - db_path = Path(corpus_path).expanduser() / ".index" / "sqlite" / "system.db" - if not db_path.exists(): - print( - f" [warn] cascade queue db not found at {db_path}; " - f"falling back to fixed {max_wait_s}s sleep" - ) - time.sleep(max_wait_s) - return - - print( - f" Polling cascade queue at {db_path} (max {max_wait_s}s, " - f"interval {poll_interval_s}s)..." - ) - deadline = time.time() + max_wait_s - last_pending = -1 - while time.time() < deadline: - try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) - n = conn.execute( - "SELECT COUNT(*) FROM md_change_state " - "WHERE status IN ('pending', 'processing')" - ).fetchone()[0] - conn.close() - except sqlite3.Error as e: - print(f" [warn] sqlite read failed: {e}; assume drained") - return - if n == 0: - elapsed = max_wait_s - (deadline - time.time()) - print(f" cascade drained after {elapsed:.1f}s") - return - if n != last_pending: - print(f" cascade pending = {n}") - last_pending = n - time.sleep(poll_interval_s) - print( - f" [warn] cascade still has {last_pending} pending after " - f"{max_wait_s}s; proceeding anyway" - ) - - -# ============================================================================= -# Data loading — preserve LoCoMo session_N structure for per-session flushing -# ============================================================================= - - -def _parse_session_timestamp(ts_str: str) -> int: - """Parse LoCoMo timestamp string to epoch milliseconds. - - Format examples: "1:56 pm on 8 May, 2023", "12:09 am on 13 September, 2023". - - LoCoMo's raw timestamps carry no timezone, so we pin them to UTC — - matching ``everalgo/benchmarks/datasets/locomo/loader.py:_parse_timestamp``. - Without an explicit tz, ``naive_dt.timestamp()`` would shift epochs by - the OS's local-vs-UTC offset, so the same dataset would produce - different absolute timestamps on different machines. - """ - dt = datetime.strptime(ts_str.strip(), "%I:%M %p on %d %B, %Y") - return int(dt.replace(tzinfo=UTC).timestamp() * 1000) - - -def load_conversation( - data_path: str, conv_index: int -) -> tuple[list[dict], list[dict], str, str]: - """Load a LoCoMo conversation, preserving session_N boundaries. - - Returns (sessions, qa_list, speaker_a, speaker_b) where `sessions` is - a list of {session_idx, messages} ordered by session_idx. Each message - carries dia_id / speaker / text / timestamp_ms. QA list excludes - category 5 (adversarial). - """ - with open(data_path, encoding="utf-8") as f: - dataset = json.load(f) - - if conv_index >= len(dataset): - raise ValueError( - f"conv_index {conv_index} out of range (dataset has {len(dataset)} conversations)" - ) - - conv = dataset[conv_index] - conversation = conv["conversation"] - speaker_a = conversation["speaker_a"] - speaker_b = conversation["speaker_b"] - - sessions: list[dict] = [] - session_idx = 1 - while True: - session_key = f"session_{session_idx}" - dt_key = f"session_{session_idx}_date_time" - if dt_key not in conversation: - break - if session_key in conversation: - ts_str = conversation[dt_key] - base_ts_ms = _parse_session_timestamp(ts_str) - session_msgs = conversation[session_key] - if isinstance(session_msgs, list): - msgs: list[dict] = [] - for i, msg in enumerate(session_msgs): - if not msg.get("text"): - continue # skip image-only messages - msgs.append( - { - "dia_id": msg["dia_id"], - "speaker": msg["speaker"], - "text": msg["text"], - "timestamp_ms": base_ts_ms + i * 30000, - } - ) - if msgs: - sessions.append({"session_idx": session_idx, "messages": msgs}) - session_idx += 1 - - qa_list = [q for q in conv.get("qa", []) if q.get("category") != 5] - return sessions, qa_list, speaker_a, speaker_b - - -# ============================================================================= -# Add phase — one everos session_id per LoCoMo session, flush after each -# ============================================================================= - - -def run_add_phase( - client: EverosClient, - sessions: list[dict], - speaker_a: str, - speaker_b: str, - conv_index: int, - batch_size: int, - quiet: bool = False, -) -> dict[str, Any]: - """Send each LoCoMo session to its own everos session_id and flush.""" - print_section("Add Phase") - total_msgs = sum(len(s["messages"]) for s in sessions) - print( - f" LoCoMo sessions: {len(sessions)} | Messages: {total_msgs} | " - f"Batch size: {batch_size} | Speakers: {speaker_a} & {speaker_b}" - ) - - t0 = time.perf_counter() - total_batches = 0 - - for sess in _progress(sessions, desc="Add+Flush", total=len(sessions), quiet=quiet): - session_id = f"locomo_conv{conv_index}_s{sess['session_idx']}" - api_messages: list[dict] = [ - { - # Append `_conv{N}` so the same speaker name across conversations - # (e.g. "John" appears in conv_2, conv_4, conv_6) does NOT collide - # on a shared owner_id partition. Without the suffix, repeated - # benchmark runs cross-pollute each other's memory store. - "sender_id": f"{msg['speaker'].lower()}_conv{conv_index}", - "sender_name": msg["speaker"], - "role": "user", - "timestamp": msg["timestamp_ms"], - "content": [{"type": "text", "text": msg["text"]}], - } - for msg in sess["messages"] - ] - - batches = [ - api_messages[i : i + batch_size] - for i in range(0, len(api_messages), batch_size) - ] - for idx, batch in enumerate(batches): - payload = {"session_id": session_id, "messages": batch} - status, resp = client.post("/api/v1/memory/add", payload, quiet=quiet) - if not quiet: - print( - f" Session {sess['session_idx']} batch {idx + 1}/{len(batches)}: " - f"{len(batch)} msgs -> status {status}" - ) - assert status == 200, ( - f"Add (session_id={session_id}, batch {idx + 1}) failed: " - f"status={status} resp={resp}" - ) - total_batches += len(batches) - - flush_status, flush_resp = client.post( - "/api/v1/memory/flush", {"session_id": session_id}, quiet=quiet - ) - assert flush_status == 200, ( - f"Flush (session_id={session_id}) failed: " - f"status={flush_status} resp={flush_resp}" - ) - - add_time = time.perf_counter() - t0 - result = { - "total_messages": total_msgs, - "session_count": len(sessions), - "batch_count": total_batches, - "batch_size": batch_size, - "add_time_seconds": round(add_time, 2), - } - print( - f" Done: {total_msgs} msgs across {len(sessions)} sessions " - f"({total_batches} batches), {add_time:.2f}s incl. flushes" - ) - return result - - -# ============================================================================= -# Search phase — single-owner partition (Plan C) -# ============================================================================= - - -def _search_one( - i: int, - qa: dict, - *, - client: EverosClient, - method: str, - top_k: int, - owner_id: str, -) -> dict: - """Search a single QA question; safe to run in a thread. - - The per-request timeout is handled by ``EverosClient.post`` via the - underlying ``requests`` ``timeout=(10, self.timeout)`` kwarg (self.timeout - defaults to 300 s), so no extra thread-based wrapping is needed here. - """ - question = qa["question"] - payload: dict = { - "query": question, - "method": method, - "top_k": top_k, - "user_id": owner_id, - } - t0 = time.perf_counter() - try: - status, resp = client.post("/api/v1/memory/search", payload, quiet=True) - except Exception as e: - status, resp = -1, {"error": str(e)} - search_time = time.perf_counter() - t0 - - if status != 200: - error_detail = resp.get("detail", resp) if isinstance(resp, dict) else resp - return { - "index": i, - "question": question, - "golden_answer": qa["answer"], - "category": qa.get("category"), - "evidence": qa.get("evidence", []), - "episodes": [], - "profiles": [], - "search_time_s": round(search_time, 4), - "search_error": error_detail, - "_search_status": status, - } - - data = resp.get("data", {}) - episodes = data.get("episodes", []) - profiles = data.get("profiles", []) - return { - "index": i, - "question": question, - "golden_answer": qa["answer"], - "category": qa.get("category"), - "evidence": qa.get("evidence", []), - "episodes": episodes, - "profiles": profiles, - "search_time_s": round(search_time, 4), - } - - -def run_search_phase( - client: EverosClient, - qa_list: list[dict], - owner_id: str, - method: str, - top_k: int, - quiet: bool = False, - concurrency: int = 10, -) -> list[dict]: - """Search for each QA question against a single owner_id partition (parallel). - - Vector retrieval strategy (``episode`` vs ``maxsim_atomic``) is selected - on the server side via ``EVEROS_SEARCH__VECTOR_STRATEGY`` — this driver - just hits the public ``/api/v1/memory/search`` endpoint and reports - what the server returned. - """ - print_section(f"Search Phase (method={method}, top_k={top_k}, owner_id={owner_id})") - - def _worker(i: int, qa: dict) -> dict: - return _search_one( - i, - qa, - client=client, - method=method, - top_k=top_k, - owner_id=owner_id, - ) - - raw = _parallel_map( - qa_list, - _worker, - desc="Search", - total=len(qa_list), - quiet=quiet, - concurrency=concurrency, - ) - - # Unwrap: _parallel_map stores exceptions as values; surface them as error dicts. - results: list[dict] = [] - for item in raw: - if isinstance(item, Exception): - results.append( - { - "episodes": [], - "profiles": [], - "search_time_s": 0, - "search_error": str(item), - } - ) - else: - results.append(item) - - errors = [r for r in results if r.get("search_error")] - search_times = [r["search_time_s"] for r in results if not r.get("search_error")] - success_count = len(results) - len(errors) - summary_parts = [f"Done: {success_count}/{len(results)} succeeded"] - if search_times: - summary_parts.append(f"avg={statistics.mean(search_times):.3f}s") - if errors: - summary_parts.append(f"{len(errors)} FAILED") - for err in errors: - print( - f" ERROR Q{err.get('index', '?')}: " - f"status={err.get('_search_status', 'exc')} | " - f"{str(err.get('question', ''))[:60]}" - ) - print(f" {', '.join(summary_parts)}") - - # Strip internal bookkeeping key before returning. - for r in results: - r.pop("_search_status", None) - return results - - -# ============================================================================= -# Answer phase -# ============================================================================= - - -_CONTEXT_TEMPLATE = """Episodes memories for conversation between {speaker_a} and {speaker_b}: - - {episodes} -""" - - -def _build_context( - episodes: list[dict], profiles: list[dict], speaker_a: str, speaker_b: str -) -> str: - """Build context string from search results. - - Matches the benchmark's context format: each episode renders as - ``{subject}: {episode_text}\\n---`` with double-newline separators. - Profile memories are intentionally omitted (benchmark doesn't use them). - """ - episode_lines = [ - f"{ep.get('subject', 'N/A')}: " - f"{ep.get('episode') or ep.get('summary') or ep.get('content') or 'N/A'}\n---" - for ep in episodes - ] - return _CONTEXT_TEMPLATE.format( - speaker_a=speaker_a, - speaker_b=speaker_b, - episodes="\n\n".join(episode_lines), - ) - - -def _extract_final_answer(text: str) -> str: - """Extract the final answer using a 3-marker priority chain. - - Matches the benchmark's extraction logic (``answer.py:_extract_final_answer``): - 1. ``## STEP 7: FINAL ANSWER`` (prompt STEP 7 section header) - 2. ``FINAL ANSWER:`` (colon-suffixed) - 3. ``FINAL ANSWER`` (bare — leading colon stripped if present) - - Each marker uses ``rsplit`` to take the LAST occurrence (handles marker - appearing in reasoning prose before the actual answer). - """ - result = text.strip() - for marker in ("## STEP 7: FINAL ANSWER", "FINAL ANSWER:", "FINAL ANSWER"): - if marker in result: - answer = result.rsplit(marker, 1)[1].strip() - # Bare "FINAL ANSWER" may have a leading ":" — strip it - if marker == "FINAL ANSWER" and answer.startswith(":"): - answer = answer[1:].strip() - return answer - return result - - -_ANSWER_MAX_RETRIES = 5 - - -def _answer_one( - i: int, - sr: dict, - *, - speaker_a: str, - speaker_b: str, - llm_client: LLMClientPool, - llm_model: str, -) -> dict: - """Generate an answer for a single search result; safe to run in a thread. - - Retry up to 5x with temperature=0.0 on every attempt (matching benchmark's - ``_retry_llm_answer``). max_tokens=32768 is set explicitly to match the - benchmark. - - The openai ``timeout=300`` kwarg is a per-request socket deadline passed - directly to the underlying HTTP client, which is safe to use from a thread - pool (no extra nesting needed). - """ - if sr.get("search_error"): - return {**sr, "generated_answer": "[SEARCH_FAILED]", "answer_time_s": 0} - - context = _build_context(sr["episodes"], sr["profiles"], speaker_a, speaker_b) - prompt = ANSWER_PROMPT.format(context=context, question=sr["question"]) - - t0 = time.perf_counter() - raw_answer = "" - generated_answer = "" - last_error: str | None = None - attempts_used = 0 - for attempt in range(_ANSWER_MAX_RETRIES): - attempts_used = attempt + 1 - try: - r = llm_client.chat.completions.create( - model=llm_model, - messages=[{"role": "user", "content": prompt}], - temperature=0.0, - max_tokens=32768, - timeout=300, - ) - raw_answer = r.choices[0].message.content or "" - except Exception as e: - last_error = f"[ERROR: {e}]" - raw_answer = last_error - if attempt < _ANSWER_MAX_RETRIES - 1: - time.sleep(1.0 * (2**attempt)) - continue - - generated_answer = _extract_final_answer(raw_answer) - if generated_answer.strip(): - break - if attempt < _ANSWER_MAX_RETRIES - 1: - time.sleep(1.0 * (2**attempt)) - - if not generated_answer.strip() and last_error: - generated_answer = last_error - - answer_time = time.perf_counter() - t0 - return { - **sr, - "generated_answer": generated_answer, - "answer_time_s": round(answer_time, 4), - "answer_attempts": attempts_used, - } - - -def run_answer_phase( - search_results: list[dict], - speaker_a: str, - speaker_b: str, - llm_client: LLMClientPool, - llm_model: str, - quiet: bool = False, - concurrency: int = 8, -) -> list[dict]: - """Generate answers using LLM for each search result (parallel).""" - print_section("Answer Phase") - - def _worker(i: int, sr: dict) -> dict: - return _answer_one( - i, - sr, - speaker_a=speaker_a, - speaker_b=speaker_b, - llm_client=llm_client, - llm_model=llm_model, - ) - - raw = _parallel_map( - search_results, - _worker, - desc="Answer", - total=len(search_results), - quiet=quiet, - concurrency=concurrency, - ) - # Unwrap: _parallel_map stores exceptions as values; surface them as error strings. - results: list[dict] = [] - for item in raw: - if isinstance(item, Exception): - results.append({"generated_answer": f"[ERROR: {item}]", "answer_time_s": 0}) - else: - results.append(item) - - answer_times = [ - r["answer_time_s"] for r in results if r.get("answer_time_s", 0) > 0 - ] - avg_str = f", avg={statistics.mean(answer_times):.2f}s" if answer_times else "" - skipped = sum(1 for r in results if r.get("search_error")) - skip_str = f", {skipped} skipped (search failed)" if skipped else "" - print(f" Done: {len(results)} answers{avg_str}{skip_str}") - return results - - -# ============================================================================= -# Evaluate phase — LLM-as-Judge -# ============================================================================= - - -def _extract_json(content: str) -> str | None: - """Robustly extract JSON from LLM response.""" - m = re.search(r"```(?:json)?\s*(\{[^`]*\})\s*```", content, re.DOTALL) - if m: - return m.group(1).strip() - m = re.search(r'\{[^{}]*"label"\s*:\s*"[^"]*"[^{}]*\}', content) - if m: - return m.group(0) - return content.strip() - - -_JUDGE_MAX_RETRIES = 5 - - -def _judge_single( - llm_client: LLMClientPool, - llm_model: str, - question: str, - golden_answer: str, - generated_answer: str, -) -> bool: - """Judge a single answer. Returns True if CORRECT. - - Retries up to ``_JUDGE_MAX_RETRIES`` times on any error (API failures, - JSON parse errors, missing label) with exponential backoff, matching the - benchmark's ``llm_retry(max_attempts=config.llm_max_retries)`` pattern. - Defaults to WRONG only after all retries are exhausted. - - Uses ``timeout=300`` passed directly to the openai HTTP client so this - function is safe to call from a thread pool without further nesting. - """ - user_prompt = JUDGE_USER_PROMPT.format( - question=question, - golden_answer=golden_answer, - generated_answer=generated_answer, - ) - for attempt in range(_JUDGE_MAX_RETRIES): - try: - r = llm_client.chat.completions.create( - model=llm_model, - messages=[ - {"role": "system", "content": JUDGE_SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt}, - ], - temperature=0, - timeout=300, - ) - content = r.choices[0].message.content or "" - json_str = _extract_json(content) - if not json_str: - raise ValueError("Empty JSON from judge response") - result = json.loads(json_str) - label = result.get("label", "").strip().upper() - if label not in ("CORRECT", "WRONG"): - raise ValueError(f"Unknown judge label: {label!r}") - return label == "CORRECT" - except Exception as e: # noqa: BLE001 - if attempt < _JUDGE_MAX_RETRIES - 1: - time.sleep(0.5 * (2**attempt)) - continue - print(f" Judge error after {_JUDGE_MAX_RETRIES} retries: {e}") - return False - return False # unreachable, but satisfies type checker - - -def _evaluate_one( - i: int, - ar: dict, - *, - llm_client: LLMClientPool, - llm_model: str, - judge_runs: int, -) -> dict: - """Evaluate a single answer result with majority-vote judging. - - Safe to run from a thread pool; exception handling mirrors the original - serial loop so a single LLM failure degrades gracefully. - """ - if ar.get("search_error"): - return {**ar, "judgments": [], "is_correct": False} - - judgments: list[bool] = [] - for _ in range(judge_runs): - judgments.append( - _judge_single( - llm_client, - llm_model, - ar["question"], - ar["golden_answer"], - ar["generated_answer"], - ) - ) - - correct = sum(judgments) > judge_runs / 2 - return {**ar, "judgments": judgments, "is_correct": correct} - - -def run_evaluate_phase( - answer_results: list[dict], - llm_client: LLMClientPool, - llm_model: str, - judge_runs: int = 1, - quiet: bool = False, - concurrency: int = 8, -) -> list[dict]: - """Evaluate answers using LLM judge (parallel).""" - print_section(f"Evaluate Phase (judge_runs={judge_runs})") - - def _worker(i: int, ar: dict) -> dict: - return _evaluate_one( - i, - ar, - llm_client=llm_client, - llm_model=llm_model, - judge_runs=judge_runs, - ) - - raw = _parallel_map( - answer_results, - _worker, - desc="Evaluate", - total=len(answer_results), - quiet=quiet, - concurrency=concurrency, - ) - results: list[dict] = [] - for item in raw: - if isinstance(item, Exception): - results.append({"judgments": [], "is_correct": False}) - else: - results.append(item) - - correct_count = sum(1 for r in results if r["is_correct"]) - print( - f" Done: {correct_count}/{len(results)} correct ({_pct(correct_count, len(results))})" - ) - return results - - -# ============================================================================= -# Reporting -# ============================================================================= - - -def _pct(n: int, total: int) -> str: - if total == 0: - return "N/A" - return f"{n / total * 100:.1f}%" - - -def _percentiles(values: list[float]) -> tuple[float, float, float]: - """Return P50, P90, P99.""" - if not values: - return 0.0, 0.0, 0.0 - s = sorted(values) - n = len(s) - - def _p(pct: float) -> float: - idx = int(pct / 100 * (n - 1)) - return s[min(idx, n - 1)] - - return _p(50), _p(90), _p(99) - - -def print_report( - eval_results: list[dict], - method: str, - add_result: dict | None, - conv_label: str = "", - top_k: int = 10, -) -> dict[str, Any]: - """Print formatted report and return summary dict.""" - total = len(eval_results) - correct = sum(1 for r in eval_results if r["is_correct"]) - search_errors = sum(1 for r in eval_results if r.get("search_error")) - - cat_stats: dict[int, dict[str, int]] = {} - for r in eval_results: - cat = r.get("category") - if cat is None: - continue - if cat not in cat_stats: - cat_stats[cat] = {"correct": 0, "total": 0} - cat_stats[cat]["total"] += 1 - if r["is_correct"]: - cat_stats[cat]["correct"] += 1 - - search_times = [ - r["search_time_s"] for r in eval_results if not r.get("search_error") - ] - answer_times = [r["answer_time_s"] for r in eval_results if r["answer_time_s"] > 0] - s_p50, s_p90, s_p99 = _percentiles(search_times) - a_p50, a_p90, a_p99 = _percentiles(answer_times) - - today = datetime.now().strftime("%Y-%m-%d") - - print(f"\n{'=' * 64}") - print(" EverOS E2E Benchmark Report") - if conv_label: - print(f" Conversation: {conv_label}") - print( - f" Messages: {add_result['total_messages'] if add_result else 'N/A (--skip-add)'} | Questions: {total}" - ) - print(f" Search Method: {method} | top_k: {top_k}") - print(f" Date: {today}") - print(f"{'=' * 64}") - - if add_result: - print("\nADD PHASE") - print(f" Total messages: {add_result['total_messages']}") - print(f" LoCoMo sessions: {add_result['session_count']}") - print( - f" Batch size: {add_result['batch_size']} ({add_result['batch_count']} requests)" - ) - print(f" Add + flush time: {add_result['add_time_seconds']}s") - - print("\nSEARCH PHASE") - if search_times: - print(f" Avg search time: {statistics.mean(search_times):.3f}s") - print(f" P50 / P90 / P99: {s_p50:.3f}s / {s_p90:.3f}s / {s_p99:.3f}s") - else: - print(" No successful searches") - - print("\nANSWER PHASE") - if answer_times: - print(f" Avg answer time: {statistics.mean(answer_times):.2f}s") - print(f" P50 / P90 / P99: {a_p50:.2f}s / {a_p90:.2f}s / {a_p99:.2f}s") - else: - print(" No successful answers") - - if search_errors: - print(f"\nSEARCH ERRORS: {search_errors}/{total}") - - print("\nACCURACY") - print(f" Overall: {_pct(correct, total)} ({correct}/{total})") - for cat in sorted(cat_stats.keys()): - s = cat_stats[cat] - label = CATEGORY_NAMES.get(cat, f"cat-{cat}") - print( - f" Category {cat} ({label}): {_pct(s['correct'], s['total'])} ({s['correct']}/{s['total']})" - ) - - print(f"\n{'=' * 64}") - - return { - "method": method, - "total": total, - "correct": correct, - "search_errors": search_errors, - "accuracy": correct / total if total else 0, - "category_stats": { - str(k): {"correct": v["correct"], "total": v["total"]} - for k, v in cat_stats.items() - }, - "avg_search_s": round(statistics.mean(search_times), 4) if search_times else 0, - "avg_answer_s": round(statistics.mean(answer_times), 4) if answer_times else 0, - } - - -def print_comparison(all_summaries: dict[str, dict[str, Any]]): - """Print a comparison table across methods.""" - print(f"\n{'=' * 64}") - print(" METHOD COMPARISON") - print(f"{'=' * 64}") - - all_cats = set() - for s in all_summaries.values(): - all_cats.update(s.get("category_stats", {}).keys()) - cats_sorted = sorted(all_cats) - - cat_headers = [f"Cat {c}" for c in cats_sorted] - header = ( - f" {'Method':<10} | {'Overall':>8} | " - + " | ".join(f"{ch:>8}" for ch in cat_headers) - + f" | {'Avg Search':>10} | {'Avg Answer':>10}" - ) - print(header) - print(f" {'-' * (len(header) - 2)}") - - for method, summary in all_summaries.items(): - overall = f"{summary['accuracy'] * 100:.1f}%" - cat_strs = [] - for c in cats_sorted: - cs = summary.get("category_stats", {}).get(c, {}) - if cs.get("total", 0) > 0: - cat_strs.append(f"{cs['correct'] / cs['total'] * 100:.1f}%") - else: - cat_strs.append("N/A") - cat_part = " | ".join(f"{s:>8}" for s in cat_strs) - print( - f" {method:<10} | {overall:>8} | {cat_part} | " - f"{summary['avg_search_s']:>9.3f}s | {summary['avg_answer_s']:>9.2f}s" - ) - - print() - - -# ============================================================================= -# Checkpoint helpers -# ============================================================================= - - -def _summarize_episode(ep: dict) -> str: - """One-line summary of an episode for checkpoint display.""" - subject = ep.get("subject", "") - body = ep.get("episode") or ep.get("summary") or ep.get("content") or "" - if len(body) > 280: - body = body[:117] + "..." - return f"{subject}: {body}" if subject else body - - -def _compact_search_result(r: dict) -> dict: - """Compact a search result for checkpoint: strip bulky fields.""" - entry = { - "index": r["index"], - "question": r["question"], - "golden_answer": r["golden_answer"], - "category": r.get("category"), - "search_time_s": r["search_time_s"], - "episode_count": len(r.get("episodes", [])), - "profile_count": len(r.get("profiles", [])), - "episodes_preview": [ - _summarize_episode(ep) for ep in r.get("episodes", [])[:5] - ], - } - if r.get("search_error"): - entry["search_error"] = str(r["search_error"]) - return entry - - -def _compact_answer_result(r: dict) -> dict: - entry = _compact_search_result(r) - entry["generated_answer"] = r.get("generated_answer", "") - entry["answer_time_s"] = r.get("answer_time_s", 0) - return entry - - -def _compact_eval_result(r: dict) -> dict: - entry = _compact_answer_result(r) - entry["is_correct"] = r.get("is_correct", False) - entry["judgments"] = r.get("judgments", []) - return entry - - -def _build_checkpoint(phase: str, results: list[dict]) -> dict: - """Build a user-friendly checkpoint dict with summary + details.""" - if phase == "search": - compact_fn = _compact_search_result - elif phase == "answer": - compact_fn = _compact_answer_result - else: - compact_fn = _compact_eval_result - - details = [compact_fn(r) for r in results] - error_count = sum(1 for r in results if r.get("search_error")) - summary: dict[str, Any] = {"total": len(results), "errors": error_count} - - if phase == "search": - times = [r["search_time_s"] for r in results if not r.get("search_error")] - summary["avg_search_time_s"] = ( - round(statistics.mean(times), 4) if times else None - ) - elif phase == "answer": - times = [r["answer_time_s"] for r in results if r.get("answer_time_s", 0) > 0] - summary["avg_answer_time_s"] = ( - round(statistics.mean(times), 4) if times else None - ) - elif phase == "eval": - correct = sum(1 for r in results if r.get("is_correct")) - summary["correct"] = correct - summary["accuracy"] = ( - f"{correct / len(results) * 100:.1f}%" if results else "N/A" - ) - cat_stats: dict[int, dict[str, int]] = {} - for r in results: - cat = r.get("category") - if cat is None: - continue - if cat not in cat_stats: - cat_stats[cat] = {"correct": 0, "total": 0} - cat_stats[cat]["total"] += 1 - if r.get("is_correct"): - cat_stats[cat]["correct"] += 1 - summary["by_category"] = { - f"cat{k} ({CATEGORY_NAMES.get(k, '?')})": f"{v['correct']}/{v['total']}" - for k, v in sorted(cat_stats.items()) - } - - return {"summary": summary, "details": details} - - -def _save_checkpoint( - checkpoint_dir: str, filename: str, phase: str, results: list[dict] -) -> str: - """Save user-friendly checkpoint with summary + compact details.""" - os.makedirs(checkpoint_dir, exist_ok=True) - path = os.path.join(checkpoint_dir, filename) - checkpoint = _build_checkpoint(phase, results) - with open(path, "w", encoding="utf-8") as f: - json.dump(checkpoint, f, indent=2, ensure_ascii=False, default=str) - print(f" [checkpoint] {path}") - return path - - -# ============================================================================= -# CLI -# ============================================================================= - -_SUPPORTED_METHODS = ("keyword", "vector", "hybrid", "agentic") - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - description="EverOS E2E Benchmark (LoCoMo)", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - p.add_argument( - "--base-url", default="http://localhost:8000", help="everos API base URL" - ) - p.add_argument( - "--data-path", default="data/locomo10.json", help="Path to LoCoMo dataset" - ) - p.add_argument( - "--conv-index", type=int, default=0, help="Conversation index in dataset" - ) - p.add_argument( - "--methods", - default="hybrid", - help=f"Comma-separated search methods ({'/'.join(_SUPPORTED_METHODS)})", - ) - p.add_argument( - "--top-k", type=int, default=10, help="Number of results to retrieve (1..100)" - ) - p.add_argument( - "--batch-size", - type=int, - default=50, - help="Messages per add request (1..500; everos caps at 500)", - ) - p.add_argument( - "--post-flush-wait", - type=int, - default=180, - help="Seconds to wait after final flush for async indexing " - "(OME chain: extract_atomic_facts → extract_foresight → " - "extract_user_profile, plus cascade md → LanceDB sync). " - "When --corpus-path is provided, this becomes a MAX wait + " - "the script polls the cascade queue and returns as soon as " - "pending==0. Otherwise it is a fixed sleep.", - ) - p.add_argument( - "--corpus-path", - default=None, - help="Path to the everos memory root (e.g. ~/.everos-locomo-X). " - "When provided, post-flush wait switches from fixed sleep to " - "polling /.index/sqlite/system.db for md_change_state " - "pending==0, capped at --post-flush-wait seconds.", - ) - p.add_argument( - "--judge-runs", - type=int, - default=3, - help="LLM judge evaluation runs per question (majority vote, default: 3)", - ) - p.add_argument( - "--eval-owner", - default="speaker_a", - choices=["speaker_a", "speaker_b"], - help="Which speaker's memory partition to query (Plan C: single-owner eval)", - ) - p.add_argument( - "--smoke", - action="store_true", - help="Smoke mode: 2 sessions x 5 QA. Quick sanity check, not a scored run.", - ) - p.add_argument( - "--smoke-session-limit", - type=int, - default=2, - help="Max sessions to load in smoke mode (default: 2)", - ) - p.add_argument( - "--smoke-msg-limit", - type=int, - default=50, - help="Max messages per session in smoke mode (default: 50)", - ) - p.add_argument( - "--smoke-qa-limit", - type=int, - default=5, - help="Max QA pairs in smoke mode (default: 5)", - ) - p.add_argument( - "--skip-add", action="store_true", help="Skip add phase (reuse existing data)" - ) - p.add_argument( - "--quiet", action="store_true", help="Suppress per-request verbose output" - ) - p.add_argument( - "--concurrency", - type=int, - default=20, - help=( - "Number of concurrent requests in Answer and Evaluate phases " - "(default: 20). With a single API key, lower to 4 to avoid LLM rate " - "limits. Search has its own knob: --search-concurrency." - ), - ) - p.add_argument( - "--search-concurrency", - type=int, - default=10, - help=( - "Number of concurrent /search requests (default: 10). Separate from " - "--concurrency because the bottleneck is LanceDB file descriptors " - "(each BM25 query opens all active FTS index parts), not LLM rate. " - "Raise to 20+ only if FD headroom is verified." - ), - ) - p.add_argument( - "--output", type=str, default=None, help="Write full results to JSON file" - ) - p.add_argument( - "--checkpoint-dir", - type=str, - default=None, - help="Directory for intermediate checkpoint files (auto-saved after each phase)", - ) - # Answer / Judge LLM split. Resolution order for each field: - # CLI flag > ANSWER_* / JUDGE_* env > LLM_* env > built-in default - p.add_argument( - "--answer-model", - default=None, - help="Model for Answer phase (env: ANSWER_MODEL, falls back to LLM_MODEL)", - ) - p.add_argument( - "--answer-base-url", - default=None, - help="Base URL for Answer phase (env: ANSWER_BASE_URL, falls back to LLM_BASE_URL)", - ) - p.add_argument( - "--answer-api-key", - default=None, - help="API key for Answer phase (env: ANSWER_API_KEY, falls back to LLM_API_KEY)", - ) - p.add_argument( - "--judge-model", - default=None, - help="Model for Judge phase (env: JUDGE_MODEL, falls back to LLM_MODEL)", - ) - p.add_argument( - "--judge-base-url", - default=None, - help="Base URL for Judge phase (env: JUDGE_BASE_URL, falls back to LLM_BASE_URL)", - ) - p.add_argument( - "--judge-api-key", - default=None, - help="API key for Judge phase (env: JUDGE_API_KEY, falls back to LLM_API_KEY)", - ) - - args = p.parse_args() - methods = [m.strip() for m in args.methods.split(",") if m.strip()] - bad = [m for m in methods if m not in _SUPPORTED_METHODS] - if bad: - p.error(f"unsupported method(s): {bad}; supported: {_SUPPORTED_METHODS}") - args._methods = methods - return args - - -# ============================================================================= -# Main -# ============================================================================= - - -def main(): - args = parse_args() - - load_dotenv() - - # Resolution: CLI flag > ANSWER_*/JUDGE_* env > LLM_* env > EVEROS_LLM__* env > default. - # Empty strings from getenv fall through via `or`. - answer_model = ( - args.answer_model - or os.getenv("ANSWER_MODEL") - or os.getenv("LLM_MODEL") - or os.getenv("EVEROS_LLM__MODEL") - or "gpt-4.1-mini" - ) - answer_base_url = ( - args.answer_base_url - or os.getenv("ANSWER_BASE_URL") - or os.getenv("LLM_BASE_URL") - or os.getenv("EVEROS_LLM__BASE_URL") - or "https://api.openai.com/v1" - ) - answer_api_keys = _split_keys( - args.answer_api_key - or os.getenv("ANSWER_API_KEY") - or os.getenv("LLM_API_KEY") - or os.getenv("EVEROS_LLM__API_KEY") - or "" - ) - - judge_model = ( - args.judge_model - or os.getenv("JUDGE_MODEL") - or os.getenv("LLM_MODEL") - or os.getenv("EVEROS_LLM__MODEL") - or "gpt-4.1-mini" - ) - judge_base_url = ( - args.judge_base_url - or os.getenv("JUDGE_BASE_URL") - or os.getenv("LLM_BASE_URL") - or os.getenv("EVEROS_LLM__BASE_URL") - or "https://api.openai.com/v1" - ) - judge_api_keys = _split_keys( - args.judge_api_key - or os.getenv("JUDGE_API_KEY") - or os.getenv("LLM_API_KEY") - or os.getenv("EVEROS_LLM__API_KEY") - or "" - ) - - if not answer_api_keys: - print( - "ERROR: no API key for Answer phase. " - "Set --answer-api-key, ANSWER_API_KEY, or LLM_API_KEY " - "(comma-separated for key rotation)." - ) - sys.exit(1) - if not judge_api_keys: - print( - "ERROR: no API key for Judge phase. " - "Set --judge-api-key, JUDGE_API_KEY, or LLM_API_KEY " - "(comma-separated for key rotation)." - ) - sys.exit(1) - - answer_client = LLMClientPool( - answer_api_keys, base_url=answer_base_url, timeout=60, max_retries=1 - ) - # Reuse the same pool when endpoint + keys match (the common case). - if answer_base_url == judge_base_url and answer_api_keys == judge_api_keys: - judge_client = answer_client - else: - judge_client = LLMClientPool( - judge_api_keys, base_url=judge_base_url, timeout=60, max_retries=1 - ) - - print( - f" Answer LLM: {answer_model} @ {answer_base_url}" - f" ({answer_client.key_count} keys)" - ) - print( - f" Judge LLM: {judge_model} @ {judge_base_url}" - f" ({judge_client.key_count} keys)" - ) - - # 1. Load data (preserve LoCoMo session boundaries) - print_section("Loading Data") - sessions, qa_list, spk_a, spk_b = load_conversation(args.data_path, args.conv_index) - - if args.smoke: - sessions = sessions[: args.smoke_session_limit] - for s in sessions: - s["messages"] = s["messages"][: args.smoke_msg_limit] - qa_list = qa_list[: args.smoke_qa_limit] - - conv_label = f"conv_{args.conv_index} ({spk_a} & {spk_b})" - total_msgs = sum(len(s["messages"]) for s in sessions) - mode_tag = " [SMOKE]" if args.smoke else "" - print( - f" Conversation: {conv_label}{mode_tag}\n" - f" LoCoMo sessions: {len(sessions)} | Messages: {total_msgs} | " - f"QA pairs: {len(qa_list)} (excl. category 5)" - ) - - # 2. Init client + pick search owner_id - client = EverosClient(base_url=args.base_url) - # Mirror the `_conv{N}` suffix used in run_add_phase so search hits the - # right partition. - _speaker = spk_a if args.eval_owner == "speaker_a" else spk_b - owner_id = f"{_speaker.lower()}_conv{args.conv_index}" - print(f" Eval owner: {args.eval_owner} -> owner_id='{owner_id}'") - - # 3. Setup checkpoint dir - checkpoint_dir = args.checkpoint_dir - if checkpoint_dir is None: - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - checkpoint_dir = os.path.join( - "benchmark_checkpoints", f"run_{ts}_conv{args.conv_index}" - ) - - # 4. Smoke-mode defaults: less wait, single judge run. - if args.smoke: - if args.post_flush_wait == 180: - args.post_flush_wait = 60 - if args.judge_runs == 3: - args.judge_runs = 1 - - add_result = None - if not args.skip_add: - add_result = run_add_phase( - client, - sessions, - spk_a, - spk_b, - args.conv_index, - args.batch_size, - quiet=args.quiet, - ) - if args.post_flush_wait > 0: - if args.corpus_path: - _wait_cascade_drain(args.corpus_path, args.post_flush_wait) - else: - print(f" Waiting {args.post_flush_wait}s for async indexing...") - time.sleep(args.post_flush_wait) - else: - print_section("Add Phase (SKIPPED)") - print(f" --skip-add: reusing existing data for owner_id='{owner_id}'") - - # 5. For each method: Search -> Answer -> Evaluate - all_method_results: dict[str, list[dict]] = {} - all_summaries: dict[str, dict[str, Any]] = {} - - for method in args._methods: - search_results = run_search_phase( - client, - qa_list, - owner_id, - method, - args.top_k, - quiet=args.quiet, - concurrency=args.search_concurrency, - ) - _save_checkpoint( - checkpoint_dir, f"{method}_1_search.json", "search", search_results - ) - - answer_results = run_answer_phase( - search_results, - spk_a, - spk_b, - answer_client, - answer_model, - quiet=args.quiet, - concurrency=args.concurrency, - ) - _save_checkpoint( - checkpoint_dir, f"{method}_2_answer.json", "answer", answer_results - ) - - eval_results = run_evaluate_phase( - answer_results, - judge_client, - judge_model, - args.judge_runs, - quiet=args.quiet, - concurrency=args.concurrency, - ) - _save_checkpoint(checkpoint_dir, f"{method}_3_eval.json", "eval", eval_results) - - all_method_results[method] = eval_results - summary = print_report(eval_results, method, add_result, conv_label, args.top_k) - all_summaries[method] = summary - - # 6. Comparison table - if len(all_summaries) > 1: - print_comparison(all_summaries) - - # 7. Optional JSON export - if args.output: - export = { - "conv_index": args.conv_index, - "conv_label": conv_label, - "eval_owner": args.eval_owner, - "owner_id": owner_id, - "add_result": add_result, - "methods": {}, - } - for method, results in all_method_results.items(): - serializable = [] - for r in results: - entry = { - "index": r["index"], - "question": r["question"], - "golden_answer": r["golden_answer"], - "generated_answer": r["generated_answer"], - "category": r["category"], - "is_correct": r["is_correct"], - "judgments": r["judgments"], - "search_time_s": r["search_time_s"], - "answer_time_s": r["answer_time_s"], - "episode_count": len(r.get("episodes", [])), - } - if r.get("search_error"): - entry["search_error"] = str(r["search_error"]) - serializable.append(entry) - export["methods"][method] = { - "summary": all_summaries[method], - "details": serializable, - } - - with open(args.output, "w", encoding="utf-8") as f: - json.dump(export, f, indent=2, ensure_ascii=False) - print(f"\nResults saved to {args.output}") - - -if __name__ == "__main__": - main() diff --git a/tests/unit/test_benchmark_config.py b/tests/unit/test_benchmark_config.py new file mode 100644 index 000000000..6b2181d01 --- /dev/null +++ b/tests/unit/test_benchmark_config.py @@ -0,0 +1,163 @@ +"""Unit tests for BenchmarkConfig loading.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def test_defaults_construct_without_error() -> None: + """BenchmarkConfig() with defaults must construct and have all fields.""" + from benchmarks.config import BenchmarkConfig + + cfg = BenchmarkConfig() + assert isinstance(cfg.methods, str) + assert cfg.top_k > 0 + assert cfg.cascade_timeout > 0 + assert cfg.batch_size > 0 + assert isinstance(cfg.answer_model, str) + assert isinstance(cfg.judge_model, str) + assert cfg.judge_runs >= 1 + assert cfg.conversations_concurrency >= 1 + assert cfg.eval_concurrency >= 1 + assert cfg.search_concurrency >= 1 + + +def test_from_toml_loads_default_config() -> None: + """from_toml('config') loads benchmarks/config.toml successfully.""" + from benchmarks.config import BenchmarkConfig + + cfg = BenchmarkConfig.from_toml("config") + assert isinstance(cfg.methods, str) + assert isinstance(cfg.judge_model, str) + + +def test_from_toml_override(tmp_path: Path) -> None: + """A custom TOML overrides specific fields, rest stay default.""" + from benchmarks.config import BenchmarkConfig + + (tmp_path / "custom.toml").write_text( + 'methods = "hybrid"\ntop_k = 20\n', encoding="utf-8" + ) + cfg = BenchmarkConfig.from_toml("custom", config_dir=tmp_path) + assert cfg.methods == "hybrid" + assert cfg.top_k == 20 + assert cfg.judge_model == "gpt-4o-mini" # untouched + + +def test_from_toml_missing_file_raises() -> None: + """from_toml with nonexistent name raises FileNotFoundError.""" + from benchmarks.config import BenchmarkConfig + + with pytest.raises(FileNotFoundError): + BenchmarkConfig.from_toml("nonexistent") + + +def test_frozen_model() -> None: + """BenchmarkConfig instances are immutable.""" + from benchmarks.config import BenchmarkConfig + + cfg = BenchmarkConfig() + with pytest.raises(Exception): # noqa: B017 — ValidationError + cfg.top_k = 999 + + +def test_concurrency_fields_exist() -> None: + """Concurrency fields exist and are positive; no legacy 'concurrency' attr.""" + from benchmarks.config import BenchmarkConfig + + cfg = BenchmarkConfig() + assert cfg.conversations_concurrency >= 1 + assert cfg.eval_concurrency >= 1 + assert cfg.search_concurrency >= 1 + assert not hasattr(cfg, "concurrency") + + +def test_search_result_frozen() -> None: + from benchmarks.config import SearchResult + + sr = SearchResult( + index=0, + question="q", + golden_answer="a", + category=1, + evidence=[], + episodes=[], + profiles=[], + search_time_s=0.5, + method="agentic", + ) + assert sr.index == 0 + with pytest.raises(Exception): # noqa: B017 + sr.index = 99 + + +def test_search_result_jsonl_roundtrip() -> None: + from benchmarks.config import SearchResult + + sr = SearchResult( + index=0, + question="q", + golden_answer="a", + category=1, + evidence=["e1"], + episodes=[{"id": "ep1"}], + profiles=[], + search_time_s=0.5, + method="agentic", + ) + line = sr.model_dump_json() + restored = SearchResult.model_validate_json(line) + assert restored == sr + + +def test_answer_result_tracks_tokens() -> None: + from benchmarks.config import AnswerResult + + ar = AnswerResult( + index=0, + question="q", + golden_answer="a", + category=1, + generated_answer="ans", + answer_time_s=1.0, + answer_attempts=1, + answer_tokens=500, + ) + assert ar.answer_tokens == 500 + + +def test_judge_result_jsonl_roundtrip() -> None: + from benchmarks.config import JudgeResult + + jr = JudgeResult( + index=0, + question="q", + golden_answer="a", + generated_answer="ans", + category=1, + is_correct=True, + judgments=[True, True, False], + judge_tokens=200, + ) + line = jr.model_dump_json() + restored = JudgeResult.model_validate_json(line) + assert restored == jr + assert restored.is_correct is True + + +def test_run_spec_includes_git_hash() -> None: + from benchmarks.config import RunSpec + + spec = RunSpec( + run_name="v1", + config={}, + conversations=[0, 1], + stages=["add", "search"], + git_hash="abc1234", + python_version="3.12.11", + everos_version="1.1.0", + started_at="2026-06-28T22:30:00Z", + ) + assert spec.git_hash == "abc1234" diff --git a/tests/unit/test_component/test_embedding/test_factory.py b/tests/unit/test_component/test_embedding/test_factory.py index 3e6625ecb..d75a1789c 100644 --- a/tests/unit/test_component/test_embedding/test_factory.py +++ b/tests/unit/test_component/test_embedding/test_factory.py @@ -24,12 +24,6 @@ def test_raises_when_api_key_missing() -> None: build_embedding_provider(s) -def test_raises_when_api_key_empty() -> None: - s = EmbeddingSettings(model="m", api_key=SecretStr(""), base_url="https://x") - with pytest.raises(ValueError, match="EVEROS_EMBEDDING__API_KEY"): - build_embedding_provider(s) - - def test_raises_when_base_url_missing() -> None: s = EmbeddingSettings(model="m", api_key=SecretStr("k"), base_url=None) with pytest.raises(ValueError, match="EVEROS_EMBEDDING__BASE_URL"): diff --git a/tests/unit/test_component/test_llm/test_factory.py b/tests/unit/test_component/test_llm/test_factory.py index 53d161b7f..4329fceb7 100644 --- a/tests/unit/test_component/test_llm/test_factory.py +++ b/tests/unit/test_component/test_llm/test_factory.py @@ -16,12 +16,6 @@ def test_raises_when_api_key_missing() -> None: build_llm_provider(s) -def test_raises_when_api_key_empty() -> None: - s = LLMSettings(model="m", api_key=SecretStr(""), base_url="https://x") - with pytest.raises(ValueError, match="EVEROS_LLM__API_KEY"): - build_llm_provider(s) - - def test_raises_when_base_url_missing() -> None: s = LLMSettings(model="m", api_key=SecretStr("k"), base_url=None) with pytest.raises(ValueError, match="EVEROS_LLM__BASE_URL"): diff --git a/tests/unit/test_component/test_rerank/test_factory.py b/tests/unit/test_component/test_rerank/test_factory.py index c4af2a928..1f5ab1170 100644 --- a/tests/unit/test_component/test_rerank/test_factory.py +++ b/tests/unit/test_component/test_rerank/test_factory.py @@ -6,7 +6,6 @@ from pydantic import SecretStr from everos.component.rerank import ( - DashScopeRerankProvider, DeepInfraRerankProvider, VllmRerankProvider, build_rerank_provider, @@ -34,17 +33,6 @@ def test_deepinfra_requires_api_key() -> None: build_rerank_provider(s) -def test_deepinfra_rejects_empty_api_key() -> None: - s = RerankSettings( - provider="deepinfra", - model="m", - api_key=SecretStr(""), - base_url="https://api.deepinfra.com/v1/inference", - ) - with pytest.raises(ValueError, match="EVEROS_RERANK__API_KEY"): - build_rerank_provider(s) - - def test_deepinfra_builds_provider() -> None: s = RerankSettings( provider="deepinfra", @@ -77,80 +65,3 @@ def test_vllm_with_api_key() -> None: ) p = build_rerank_provider(s) assert isinstance(p, VllmRerankProvider) - - -def test_dashscope_requires_api_key() -> None: - s = RerankSettings( - provider="dashscope", - model="gte-rerank-v2", - api_key=None, - base_url="https://dashscope.aliyuncs.com", - ) - with pytest.raises(ValueError, match="EVEROS_RERANK__API_KEY"): - build_rerank_provider(s) - - -def test_dashscope_builds_provider() -> None: - s = RerankSettings( - provider="dashscope", - model="gte-rerank-v2", - api_key=SecretStr("k"), - base_url="https://dashscope.aliyuncs.com", - ) - p = build_rerank_provider(s) - assert isinstance(p, DashScopeRerankProvider) - - -def test_dashscope_supports_gte_rerank_v2_only() -> None: - s = RerankSettings( - provider="dashscope", - model="qwen3-rerank", - api_key=SecretStr("k"), - base_url="https://dashscope.aliyuncs.com", - ) - with pytest.raises(ValueError, match="gte-rerank-v2 only"): - build_rerank_provider(s) - - -def test_infers_dashscope_from_base_url() -> None: - """provider unset + DashScope host -> DashScopeRerankProvider.""" - s = RerankSettings( - provider=None, - model="gte-rerank-v2", - api_key=SecretStr("k"), - base_url="https://dashscope.aliyuncs.com", - ) - assert isinstance(build_rerank_provider(s), DashScopeRerankProvider) - - -def test_infers_deepinfra_from_base_url() -> None: - """provider unset + DeepInfra host -> DeepInfraRerankProvider.""" - s = RerankSettings( - provider=None, - model="m", - api_key=SecretStr("k"), - base_url="https://api.deepinfra.com/v1/inference", - ) - assert isinstance(build_rerank_provider(s), DeepInfraRerankProvider) - - -def test_unknown_host_falls_back_to_deepinfra() -> None: - """provider unset + unrecognized host -> historical deepinfra default.""" - s = RerankSettings( - provider=None, - model="m", - api_key=SecretStr("k"), - base_url="https://rerank.internal.example/v1", - ) - assert isinstance(build_rerank_provider(s), DeepInfraRerankProvider) - - -def test_explicit_provider_overrides_inference() -> None: - """Explicit provider wins even when the host hints another.""" - s = RerankSettings( - provider="vllm", - model="m", - api_key=SecretStr("k"), - base_url="https://dashscope.aliyuncs.com", - ) - assert isinstance(build_rerank_provider(s), VllmRerankProvider) diff --git a/tests/unit/test_config/test_settings.py b/tests/unit/test_config/test_settings.py index 1f712894e..fb4fbb17f 100644 --- a/tests/unit/test_config/test_settings.py +++ b/tests/unit/test_config/test_settings.py @@ -13,10 +13,12 @@ @pytest.fixture(autouse=True) def _isolate_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Strip EVEROS_* env vars and move CWD away from any config file.""" + """Strip EVEROS_* env vars and pin root to tmp_path so no external + everos.toml is ever discovered.""" for key in list(os.environ): if key.startswith("EVEROS_"): monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("EVEROS_ROOT", str(tmp_path)) monkeypatch.chdir(tmp_path) load_settings.cache_clear() @@ -107,47 +109,20 @@ def test_load_settings_is_cached() -> None: def test_embedding_rerank_defaults() -> None: s = Settings() - assert s.embedding.model is None - assert s.embedding.api_key is None + assert s.embedding.model == "Qwen/Qwen3-Embedding-4B" + assert s.embedding.base_url == "https://api.deepinfra.com/v1/openai" + assert s.embedding.api_key.get_secret_value() == "" assert s.embedding.timeout_seconds == 30.0 - assert s.rerank.model is None - assert s.rerank.provider is None + assert s.rerank.model == "Qwen/Qwen3-Reranker-4B" + assert s.rerank.base_url == "https://api.deepinfra.com/v1/inference" + assert s.rerank.api_key.get_secret_value() == "" assert s.rerank.timeout_seconds == 30.0 + assert s.llm.api_key.get_secret_value() == "" -def test_dashscope_one_key_can_configure_llm_embedding_and_rerank( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """One DashScope key value can be reused across all three clients.""" - key = "sk-dashscope" - compatible_base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" - - monkeypatch.setenv("EVEROS_LLM__MODEL", "qwen-plus") - monkeypatch.setenv("EVEROS_LLM__API_KEY", key) - monkeypatch.setenv("EVEROS_LLM__BASE_URL", compatible_base_url) - monkeypatch.setenv("EVEROS_EMBEDDING__MODEL", "text-embedding-v4") - monkeypatch.setenv("EVEROS_EMBEDDING__API_KEY", key) - monkeypatch.setenv("EVEROS_EMBEDDING__BASE_URL", compatible_base_url) - monkeypatch.setenv("EVEROS_RERANK__MODEL", "gte-rerank-v2") - monkeypatch.setenv("EVEROS_RERANK__API_KEY", key) - monkeypatch.setenv("EVEROS_RERANK__BASE_URL", "https://dashscope.aliyuncs.com") - - s = Settings() - - assert s.llm.api_key is not None - assert s.embedding.api_key is not None - assert s.rerank.api_key is not None - assert s.llm.api_key.get_secret_value() == key - assert s.embedding.api_key.get_secret_value() == key - assert s.rerank.api_key.get_secret_value() == key - assert s.llm.base_url == compatible_base_url - assert s.embedding.base_url == compatible_base_url - assert s.rerank.provider is None - assert s.rerank.base_url == "https://dashscope.aliyuncs.com" - - -def test_resolve_root_default() -> None: +def test_resolve_root_default(monkeypatch: pytest.MonkeyPatch) -> None: """No --root, no EVEROS_ROOT → ~/.everos.""" + monkeypatch.delenv("EVEROS_ROOT", raising=False) assert resolve_root() == Path("~/.everos").expanduser().resolve() diff --git a/tests/unit/test_core/test_middleware/test_profile.py b/tests/unit/test_core/test_middleware/test_profile.py index 67172eae4..1d95b8b0c 100644 --- a/tests/unit/test_core/test_middleware/test_profile.py +++ b/tests/unit/test_core/test_middleware/test_profile.py @@ -107,7 +107,7 @@ async def test_enabled_with_query_returns_html_when_inner_raises( pytest.skip("pyinstrument not installed in this env") # Rebuild a tiny app whose route raises so the middleware's except branch - # fires; the profile HTML is still emitted regardless. + # fires; the middleware re-raises so the error propagates normally. app = FastAPI() app.add_middleware(ProfileMiddleware) @@ -120,8 +120,7 @@ async def bang() -> None: base_url="http://test", ) as c: resp = await c.get("/bang?profile=true") - assert resp.status_code == 200 - assert "text/html" in resp.headers.get("content-type", "") + assert resp.status_code == 500 async def test_enabled_without_pyinstrument(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/unit/test_entrypoints/test_api/test_lifespans/test_ome.py b/tests/unit/test_entrypoints/test_api/test_lifespans/test_ome.py index 45447e455..b48c2d13f 100644 --- a/tests/unit/test_entrypoints/test_api/test_lifespans/test_ome.py +++ b/tests/unit/test_entrypoints/test_api/test_lifespans/test_ome.py @@ -29,7 +29,7 @@ async def test_lifespan_starts_and_stops_engine( engine = await provider.startup(app) assert engine is not None - assert engine._started is True # noqa: SLF001 — test introspection + assert engine._started is True await provider.shutdown(app) - assert engine._started is False # noqa: SLF001 + assert engine._started is False diff --git a/tests/unit/test_entrypoints/test_cli/test_main.py b/tests/unit/test_entrypoints/test_cli/test_main.py index 093697cac..8d6476679 100644 --- a/tests/unit/test_entrypoints/test_cli/test_main.py +++ b/tests/unit/test_entrypoints/test_cli/test_main.py @@ -13,7 +13,6 @@ def test_help_exits_zero() -> None: assert "everos" in result.stdout assert "server" in result.stdout assert "cascade" in result.stdout - assert "demo" in result.stdout assert "config" in result.stdout diff --git a/tests/unit/test_infra/test_lancedb/test_tables/test_content_sha256.py b/tests/unit/test_infra/test_lancedb/test_tables/test_content_sha256.py index fc25718db..ad8e40715 100644 --- a/tests/unit/test_infra/test_lancedb/test_tables/test_content_sha256.py +++ b/tests/unit/test_infra/test_lancedb/test_tables/test_content_sha256.py @@ -146,5 +146,5 @@ def test_content_sha256_required(factory) -> None: # type: ignore[no-untyped-de row = factory() kwargs = row.model_dump() del kwargs["content_sha256"] - with pytest.raises(Exception): # noqa: B017,PT011 + with pytest.raises(Exception): # noqa: B017 type(row).model_validate(kwargs) diff --git a/tests/unit/test_infra/test_markdown/test_writers/test_base.py b/tests/unit/test_infra/test_markdown/test_writers/test_base.py index 81f4bb4cf..8870c7b6e 100644 --- a/tests/unit/test_infra/test_markdown/test_writers/test_base.py +++ b/tests/unit/test_infra/test_markdown/test_writers/test_base.py @@ -130,7 +130,7 @@ async def test_current_count_hook_can_be_overridden(root: MemoryRoot) -> None: class _ConstantCount(BaseDailyWriter): schema = _UserDemoFrontmatter - async def _current_count(self, path): # noqa: ANN001 + async def _current_count(self, path): return 41 # always claim 41 existing entries writer = _ConstantCount(root) @@ -144,7 +144,7 @@ async def test_frontmatter_updates_hook_supplies_defaults(root: MemoryRoot) -> N class _WithDefaults(BaseDailyWriter): schema = _UserDemoFrontmatter - def _frontmatter_updates(self, scope_id, date, *, next_count): # noqa: ANN001 + def _frontmatter_updates(self, scope_id, date, *, next_count): return { "user_id": scope_id, "entry_count": next_count, @@ -167,7 +167,7 @@ async def test_explicit_frontmatter_updates_skip_hook(root: MemoryRoot) -> None: class _WithDefaults(BaseDailyWriter): schema = _UserDemoFrontmatter - def _frontmatter_updates(self, scope_id, date, *, next_count): # noqa: ANN001 + def _frontmatter_updates(self, scope_id, date, *, next_count): return {"marker": "from-hook"} writer = _WithDefaults(root) diff --git a/tests/unit/test_infra/test_ome/test_e2e.py b/tests/unit/test_infra/test_ome/test_e2e.py index 41a3bfb21..29deee146 100644 --- a/tests/unit/test_infra/test_ome/test_e2e.py +++ b/tests/unit/test_infra/test_ome/test_e2e.py @@ -178,7 +178,7 @@ async def cron_job(event: CronTick, ctx: StrategyContext) -> None: h.register(cron_job) await h.start() # Directly invoke what APS would call; bypass scheduler timing. - await _cron_entry(h._engine._engine_id, "cron_e2e") # noqa: SLF001 + await _cron_entry(h._engine._engine_id, "cron_e2e") await h.drain(timeout=5) runs = await h.list_runs("cron_e2e") diff --git a/tests/unit/test_infra/test_ome/test_records.py b/tests/unit/test_infra/test_ome/test_records.py index 2acd0e7f9..5cefd7cc8 100644 --- a/tests/unit/test_infra/test_ome/test_records.py +++ b/tests/unit/test_infra/test_ome/test_records.py @@ -104,7 +104,7 @@ def test_run_record_rejects_negative_int(field: str) -> None: def test_run_record_rejects_naive_started_at() -> None: - naive = datetime(2026, 5, 12, 12, 0, 0) # noqa: DTZ001 — deliberately naive + naive = datetime(2026, 5, 12, 12, 0, 0) with pytest.raises(ValidationError, match="started_at"): RunRecord(**_ok_kwargs(started_at=naive)) diff --git a/tests/unit/test_memory/test_cascade/test_handler_episode.py b/tests/unit/test_memory/test_cascade/test_handler_episode.py index b0d3ae944..5af260f4a 100644 --- a/tests/unit/test_memory/test_cascade/test_handler_episode.py +++ b/tests/unit/test_memory/test_cascade/test_handler_episode.py @@ -153,7 +153,8 @@ async def test_added_entry_upserts_typed_row( assert outcome.upserted == 1 assert outcome.deleted == 0 assert outcome.skipped == 0 - assert embedder.calls == 1 + # Subject "Test" is present → two embed calls (content + subject). + assert embedder.calls == 2 assert len(fake_repo.upserts) == 1 row = fake_repo.upserts[0][0] assert row.owner_id == "u1" @@ -165,12 +166,15 @@ async def test_added_entry_upserts_typed_row( assert row.parent_id == "mc_test_parent" assert row.parent_type == "memcell" assert row.episode == "hello world" - assert row.episode_tokens == "hello world" + # episode_tokens includes subject keywords appended after content tokens. + assert row.episode_tokens == "hello world Test" assert row.subject == "Test" assert row.md_path == rel assert row.entry_id.startswith("ep_") assert row.id == f"u1_{row.entry_id}" assert len(row.vector) == 1024 + assert row.subject_vector is not None + assert len(row.subject_vector) == 1024 async def test_unchanged_entry_is_skipped_no_embed_call( @@ -211,7 +215,40 @@ async def test_modified_entry_reembeds( outcome = await handler.handle_added_or_modified(rel) assert outcome.upserted == 1 assert outcome.skipped == 0 + # Subject "Test" is present → two embed calls (content + subject). + assert embedder.calls == 2 + + +async def test_no_subject_skips_subject_embed( + memory_root: MemoryRoot, fake_repo: _FakeEpisodeRepo +) -> None: + """When Subject is absent, subject_vector is None; only one embed call is made.""" + today = _dt.date(2026, 5, 14) + writer = EpisodeWriter(memory_root) + await writer.append_entry( + "u2", + inline={ + "owner_id": "u2", + "session_id": "s2", + "timestamp": "2026-05-14T10:00:00+00:00", + "parent_type": "memcell", + "parent_id": "mc_no_subject", + "sender_ids": ["u2"], + }, + sections={"Content": "content only"}, + date=today, + ) + rel = "default_app/default_project/users/u2/episodes/episode-2026-05-14.md" + + handler, embedder = _build_handler(memory_root) + outcome = await handler.handle_added_or_modified(rel) + + assert outcome.upserted == 1 + # No Subject → single embed call for content only. assert embedder.calls == 1 + row = fake_repo.upserts[0][0] + assert row.subject_vector is None + assert row.episode_tokens == "content only" # ── deletion paths ─────────────────────────────────────────────────────── diff --git a/tests/unit/test_memory/test_cascade/test_worker.py b/tests/unit/test_memory/test_cascade/test_worker.py index 9b3fae445..0072bc8ab 100644 --- a/tests/unit/test_memory/test_cascade/test_worker.py +++ b/tests/unit/test_memory/test_cascade/test_worker.py @@ -72,7 +72,7 @@ async def mark_failed( class _OkHandler(Handler): - def __init__(self) -> None: # noqa: D401 — no deps needed + def __init__(self) -> None: pass async def handle_added_or_modified(self, md_path: str) -> HandlerOutcome: diff --git a/tests/unit/test_memory/test_extract/test_parser/test_enrich.py b/tests/unit/test_memory/test_extract/test_parser/test_enrich.py index 8915da551..acd9793b4 100644 --- a/tests/unit/test_memory/test_extract/test_parser/test_enrich.py +++ b/tests/unit/test_memory/test_extract/test_parser/test_enrich.py @@ -9,12 +9,12 @@ pytest.importorskip("everalgo.parser") -from everalgo.llm import LLMError # noqa: E402 -from everalgo.types import ParsedContent # noqa: E402 +from everalgo.llm import LLMError +from everalgo.types import ParsedContent -from everos.component import parser as _parser_mod # noqa: E402 -from everos.core.errors import UnsupportedModalityError # noqa: E402 -from everos.memory.extract.parser import enrich_content_items # noqa: E402 +from everos.component import parser as _parser_mod +from everos.core.errors import UnsupportedModalityError +from everos.memory.extract.parser import enrich_content_items _APARSE_FILE_TARGET = "everos.component.parser.aparse_file" diff --git a/tests/unit/test_memory/test_extract/test_pipeline/test_user_memory_emits.py b/tests/unit/test_memory/test_extract/test_pipeline/test_user_memory_emits.py index 265f47de8..ab70c7ada 100644 --- a/tests/unit/test_memory/test_extract/test_pipeline/test_user_memory_emits.py +++ b/tests/unit/test_memory/test_extract/test_pipeline/test_user_memory_emits.py @@ -46,7 +46,7 @@ async def test_emit_pipeline_started_routes_through_engine() -> None: ) cell = _sample_memcell() - await pipeline._emit_pipeline_started( # noqa: SLF001 — test introspection + await pipeline._emit_pipeline_started( memcell_id="mc_a", session_id="s1", app_id="claude_code", @@ -101,7 +101,7 @@ async def test_emit_episode_extracted_after_md_write() -> None: algo_ep = AlgoEpisode( owner_id="u1", episode="they said hello", timestamp=1_700_000_000_000 ) - with patch.object( # noqa: SLF001 + with patch.object( pipeline._ep_ext, "aextract", new=AsyncMock(return_value=algo_ep) ): outcome = await pipeline.run( diff --git a/tests/unit/test_memory/test_search/conftest.py b/tests/unit/test_memory/test_search/conftest.py index 21f87306c..cf187cb59 100644 --- a/tests/unit/test_memory/test_search/conftest.py +++ b/tests/unit/test_memory/test_search/conftest.py @@ -1,27 +1,3 @@ -"""Shared fixtures for ``memory.search`` unit tests. - -The project default is ``EVEROS_SEARCH__VECTOR_STRATEGY=maxsim_atomic`` — -that path queries both the ``atomic_fact`` table and the ``episode`` table -to do MaxSim. The existing VECTOR-route tests in ``test_manager.py`` were -written against the legacy single-vector ``episode`` path and stub only the -episode recaller (atomic_fact recaller is a no-data stub). - -Force the legacy ``episode`` strategy by default for these tests so they -keep asserting against the dense-recall path they were designed to cover. -MaxSim-specific tests opt back into ``maxsim_atomic`` by overriding the env -var inside their own body. -""" +"""Shared fixtures for ``memory.search`` unit tests.""" from __future__ import annotations - -import pytest - -from everos.config.settings import load_settings - - -@pytest.fixture(autouse=True) -def _force_episode_vector_strategy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("EVEROS_SEARCH__VECTOR_STRATEGY", "episode") - load_settings.cache_clear() - yield - load_settings.cache_clear() diff --git a/tests/unit/test_memory/test_search/test_agentic.py b/tests/unit/test_memory/test_search/test_agentic.py index 846402790..9b48ee369 100644 --- a/tests/unit/test_memory/test_search/test_agentic.py +++ b/tests/unit/test_memory/test_search/test_agentic.py @@ -71,12 +71,25 @@ async def sparse_recall(self, *_: Any, **__: Any) -> list[Candidate]: async def dense_recall(self, *_: Any, **__: Any) -> list[Candidate]: return list(self._all_docs) + async def sparse_recall_as_child(self, *_: Any, **__: Any) -> list[Candidate]: + return [] + + async def dense_recall_subject_as_child( + self, *_: Any, **__: Any + ) -> list[Candidate]: + return [] + async def fetch_by_parent_ids( self, parent_ids: Sequence[str], where: str ) -> list[Candidate]: """Returns Candidate with id=episode_id (real LanceDB id).""" return [self._by_parent[p] for p in parent_ids if p in self._by_parent] + async def fetch_by_entry_ids( + self, entry_ids: Sequence[str], where: str + ) -> list[Candidate]: + return [self._by_parent[e] for e in entry_ids if e in self._by_parent] + async def fetch_all_for_owner(self, where: str) -> list[Candidate]: """Returns Candidate with id=memcell_id and metadata['episode_id'].""" return list(self._all_docs) @@ -213,6 +226,8 @@ async def fake_embed(q: str) -> list[float]: "What did Alice eat?", owner_id="alice", where="owner_id = 'alice' AND owner_type = 'user'", + app_id="test_app", + project_id="test_proj", episode_recaller=ep_recaller, atomic_fact_recaller=fact_recaller, embed_query_fn=fake_embed, @@ -252,6 +267,8 @@ async def fake_embed(q: str) -> list[float]: "q", owner_id="alice", where="owner_id = 'alice' AND owner_type = 'user'", + app_id="bench_app", + project_id="bench_proj", episode_recaller=ep_recaller, atomic_fact_recaller=fact_recaller, embed_query_fn=fake_embed, @@ -260,7 +277,12 @@ async def fake_embed(q: str) -> list[float]: top_k=10, ) - mock_list.assert_called_once_with("alice", "user_memory") + mock_list.assert_called_once_with( + "alice", + "user_memory", + app_id="bench_app", + project_id="bench_proj", + ) async def test_agentic_search_shapes_candidates_with_episode_id( @@ -290,6 +312,8 @@ async def fake_embed(q: str) -> list[float]: "What did Alice eat?", owner_id="alice", where="owner_id = 'alice' AND owner_type = 'user'", + app_id="test_app", + project_id="test_proj", episode_recaller=ep_recaller, atomic_fact_recaller=fact_recaller, embed_query_fn=fake_embed, diff --git a/tests/unit/test_memory/test_search/test_hierarchy.py b/tests/unit/test_memory/test_search/test_hierarchy.py index 7ce3c895a..796b58be9 100644 --- a/tests/unit/test_memory/test_search/test_hierarchy.py +++ b/tests/unit/test_memory/test_search/test_hierarchy.py @@ -1,49 +1,34 @@ -"""Unit tests for ``memory.search.hierarchy``. - -White-box surfaces accessed: - - ``_hierarchy_eviction_pass`` (internal, tested directly for unit coverage) - - ``hierarchy_retrieve_episodes`` (public function, tested with stubbed I/O) - -Layer 4 uses hierarchical fact eviction: parent episode and its facts are -calibrated to an LR probability via ``cosine_to_lr_score`` and compete on that -single scale, so the expected scores below are computed with the same helper -rather than hard-coded — the assertions track the calibration, not magic -numbers. - -All I/O (fact_recaller, episode_recaller) is injected via AsyncMock stubs. -No LanceDB or network calls are made. -""" +"""Unit tests for ``memory.search.hierarchy``.""" from __future__ import annotations import datetime as _dt -from unittest.mock import AsyncMock, MagicMock import pytest from everalgo.rank.fusion import cosine_to_lr_score from everalgo.types import Candidate, FactCandidate from everos.memory.search.hierarchy import ( - _build_ep_to_fact_parents, - _hierarchy_eviction_pass, - hierarchy_retrieve_episodes, + build_ep_to_fact_parents, + heap_expand, ) -# ── Fixtures / helpers ─────────────────────────────────────────────────── +# ── Helpers ────────────────────────────────────────────────────────────── def _ts() -> _dt.datetime: return _dt.datetime(2026, 1, 1, tzinfo=_dt.UTC) -def _episode_candidate( +def _ep( *, ep_id: str = "ep-1", score: float = 0.7, memcell_id: str = "mc-1", entry_id: str | None = None, + source: str = "vector", ) -> Candidate: - metadata = { + metadata: dict = { "parent_id": memcell_id, "owner_id": "u1", "owner_type": "user", @@ -56,15 +41,10 @@ def _episode_candidate( } if entry_id is not None: metadata["entry_id"] = entry_id - return Candidate( - id=ep_id, - score=score, - source="vector", - metadata=metadata, - ) + return Candidate(id=ep_id, score=score, source=source, metadata=metadata) -def _fact_candidate( +def _fact( *, fact_id: str = "fact-1", parent_episode_id: str = "ep-1", @@ -78,428 +58,137 @@ def _fact_candidate( ) -def _make_recallers( - *, - dense_facts: list[Candidate] | None = None, - fetched_episodes: list[Candidate] | None = None, - facts_for_episodes: dict[str, list[FactCandidate]] | None = None, -) -> tuple[MagicMock, MagicMock]: - """Build stubbed fact_recaller and episode_recaller.""" - fact_recaller = MagicMock() - fact_recaller.dense_recall = AsyncMock(return_value=dense_facts or []) - fact_recaller.facts_for_episodes = AsyncMock(return_value=facts_for_episodes or {}) - - episode_recaller = MagicMock() - episode_recaller.fetch_by_entry_ids = AsyncMock(return_value=fetched_episodes or []) +# ── build_ep_to_fact_parents ───────────────────────────────────────────── - return fact_recaller, episode_recaller +class TestBuildEpToFactParents: + def test_entry_id_only(self) -> None: + """entry_id equal to parent_id → deduped to a single parent.""" + ep = _ep(ep_id="ep-1", memcell_id="ent-1", entry_id="ent-1") + result = build_ep_to_fact_parents([ep]) + assert result == {"ep-1": ["ent-1"]} + + def test_parent_id_only(self) -> None: + ep = _ep(ep_id="ep-1", memcell_id="mc-1") + result = build_ep_to_fact_parents([ep]) + assert result == {"ep-1": ["mc-1"]} -# ── _hierarchy_eviction_pass unit tests ───────────────────────────────── - - -class TestHierarchyEvictionPass: - def test_fact_wins_emits_atomic_fact_at_lr_score(self) -> None: - # Fact cosine (0.9) > parent cosine (0.5) → fact wins; alpha=1.0 so the - # emitted score is the fact's own LR-calibrated value. - episode = _episode_candidate(ep_id="ep-1") - fact = _fact_candidate(fact_id="fact-1", parent_episode_id="ep-1", score=0.9) - - result = _hierarchy_eviction_pass( - [episode], - {"ep-1": [fact]}, - ep_cosine={"ep-1": 0.5}, - ep_bm25={}, - ) - - assert len(result) == 1 - item = result[0] - assert item.item_type == "atomic_fact" - assert item.id == "fact-1" - assert item.score == pytest.approx(cosine_to_lr_score(0.9, 0.0)) - - def test_episode_wins_emits_episode_at_parent_lr_score(self) -> None: - # Fact cosine (0.6) < parent cosine (0.8) → episode wins at parent_lr. - episode = _episode_candidate(ep_id="ep-1") - fact = _fact_candidate(fact_id="fact-1", parent_episode_id="ep-1", score=0.6) - - result = _hierarchy_eviction_pass( - [episode], - {"ep-1": [fact]}, - ep_cosine={"ep-1": 0.8}, - ep_bm25={}, - ) + def test_both_entry_and_parent(self) -> None: + ep = _ep(ep_id="ep-1", memcell_id="mc-1", entry_id="ent-1") + result = build_ep_to_fact_parents([ep]) + assert result == {"ep-1": ["ent-1", "mc-1"]} + + def test_empty_list(self) -> None: + assert build_ep_to_fact_parents([]) == {} + + def test_no_ids_in_metadata(self) -> None: + ep = Candidate(id="ep-1", score=0.5, metadata={}) + assert build_ep_to_fact_parents([ep]) == {} + + +# ── heap_expand ────────────────────────────────────────────────────────── + + +class TestHeapExpand: + def test_empty_inputs_returns_empty(self) -> None: + assert heap_expand(sparse=[], dense=[], episode_to_facts={}) == [] + + def test_episodes_only_no_facts_sorted_by_lr(self) -> None: + """No facts → all episodes survive, sorted by LR score descending.""" + sparse = [_ep(ep_id="ep-a", score=5.0), _ep(ep_id="ep-b", score=3.0)] + dense = [_ep(ep_id="ep-a", score=0.8), _ep(ep_id="ep-b", score=0.6)] + + result = heap_expand(sparse=sparse, dense=dense, episode_to_facts={}, top_k=2) + + assert len(result) == 2 + assert result[0].id == "ep-a" + assert result[1].id == "ep-b" + assert all(r.item_type == "episode" for r in result) + assert result[0].score == pytest.approx(cosine_to_lr_score(0.8, 5.0)) + assert result[1].score == pytest.approx(cosine_to_lr_score(0.6, 3.0)) + + def test_fact_evicts_parent_episode(self) -> None: + """A high-scoring fact enters top-N and evicts its parent episode.""" + sparse = [_ep(ep_id="ep-1", score=2.0)] + dense = [_ep(ep_id="ep-1", score=0.5)] + facts = {"ep-1": [_fact(fact_id="f1", parent_episode_id="ep-1", score=0.95)]} + + result = heap_expand( + sparse=sparse, dense=dense, episode_to_facts=facts, top_k=2 + ) + + fact_items = [r for r in result if r.item_type == "atomic_fact"] + ep_items = [r for r in result if r.item_type == "episode"] + assert len(fact_items) == 1 + assert fact_items[0].id == "f1" + assert fact_items[0].parent_episode_id == "ep-1" + assert len(ep_items) == 0 + + def test_global_competition_fact_evicts_weaker_episode(self) -> None: + """Fact from ep-a can push ep-b out of top-N if ep-b scores lower.""" + sparse = [ + _ep(ep_id="ep-a", score=4.0), + _ep(ep_id="ep-b", score=1.0), + ] + dense = [ + _ep(ep_id="ep-a", score=0.7), + _ep(ep_id="ep-b", score=0.3), + ] + facts = { + "ep-a": [ + _fact(fact_id="f1", parent_episode_id="ep-a", score=0.95), + _fact(fact_id="f2", parent_episode_id="ep-a", score=0.90), + ], + } - assert len(result) == 1 - item = result[0] - assert item.item_type == "episode" - assert item.id == "ep-1" - assert item.score == pytest.approx(cosine_to_lr_score(0.8, 0.0)) - - def test_no_facts_emits_episode_at_parent_lr(self) -> None: - episode = _episode_candidate(ep_id="ep-1") - - result = _hierarchy_eviction_pass( - [episode], - {}, - ep_cosine={"ep-1": 0.7}, - ep_bm25={}, + result = heap_expand( + sparse=sparse, dense=dense, episode_to_facts=facts, top_k=1 ) assert len(result) == 1 - assert result[0].item_type == "episode" - assert result[0].id == "ep-1" - assert result[0].score == pytest.approx(cosine_to_lr_score(0.7, 0.0)) - - def test_ordering_preserved_matches_input_order(self) -> None: - ep_a = _episode_candidate(ep_id="ep-a", memcell_id="mc-a") - ep_b = _episode_candidate(ep_id="ep-b", memcell_id="mc-b") - ep_c = _episode_candidate(ep_id="ep-c", memcell_id="mc-c") - merged = [ep_a, ep_b, ep_c] - - result = _hierarchy_eviction_pass( - merged, - {}, - ep_cosine={"ep-a": 0.9, "ep-b": 0.8, "ep-c": 0.7}, - ep_bm25={}, - ) - - assert [r.id for r in result] == ["ep-a", "ep-b", "ep-c"] - - def test_parent_episode_id_set_on_evicted_fact(self) -> None: - episode = _episode_candidate(ep_id="ep-1") - fact = _fact_candidate(fact_id="fact-1", parent_episode_id="ep-1", score=0.8) - - result = _hierarchy_eviction_pass( - [episode], - {"ep-1": [fact]}, - ep_cosine={"ep-1": 0.4}, - ep_bm25={}, - ) - - assert result[0].parent_episode_id == "ep-1" - - def test_episode_wins_parent_episode_id_is_none(self) -> None: - episode = _episode_candidate(ep_id="ep-1") - fact = _fact_candidate(fact_id="fact-1", parent_episode_id="ep-1", score=0.5) - - result = _hierarchy_eviction_pass( - [episode], - {"ep-1": [fact]}, - ep_cosine={"ep-1": 0.9}, - ep_bm25={}, - ) - - assert result[0].parent_episode_id is None + assert result[0].item_type == "atomic_fact" + assert result[0].id == "f1" - def test_multiple_episodes_mixed_eviction(self) -> None: - ep1 = _episode_candidate(ep_id="ep-1", memcell_id="mc-1") - ep2 = _episode_candidate(ep_id="ep-2", memcell_id="mc-2") - ep3 = _episode_candidate(ep_id="ep-3", memcell_id="mc-3") - fact1 = _fact_candidate(fact_id="fact-1", parent_episode_id="ep-1", score=0.9) - fact2 = _fact_candidate(fact_id="fact-2", parent_episode_id="ep-2", score=0.4) + def test_top_k_caps_output(self) -> None: + sparse = [_ep(ep_id=f"ep-{i}", score=float(5 - i)) for i in range(5)] + dense = [_ep(ep_id=f"ep-{i}", score=0.9 - i * 0.1) for i in range(5)] - result = _hierarchy_eviction_pass( - [ep1, ep2, ep3], - {"ep-1": [fact1], "ep-2": [fact2]}, - ep_cosine={"ep-1": 0.5, "ep-2": 0.8, "ep-3": 0.6}, - ep_bm25={}, - ) + result = heap_expand(sparse=sparse, dense=dense, episode_to_facts={}, top_k=3) assert len(result) == 3 - assert result[0].item_type == "atomic_fact" # 0.9 > 0.5 - assert result[0].id == "fact-1" - assert result[1].item_type == "episode" # 0.4 < 0.8 - assert result[1].id == "ep-2" - assert result[2].item_type == "episode" # no fact - assert result[2].id == "ep-3" - - def test_best_fact_across_window_used_for_comparison(self) -> None: - episode = _episode_candidate(ep_id="ep-1") - best_fact = _fact_candidate( - fact_id="fact-best", parent_episode_id="ep-1", score=0.85 - ) - weak_fact = _fact_candidate( - fact_id="fact-weak", parent_episode_id="ep-1", score=0.3 - ) - result = _hierarchy_eviction_pass( - [episode], - {"ep-1": [best_fact, weak_fact]}, - ep_cosine={"ep-1": 0.7}, - ep_bm25={}, - ) + def test_convergence_stops_loop(self) -> None: + """With max_convergence_rounds=1, loop stops after 1 round of no change.""" + sparse = [_ep(ep_id="ep-1", score=2.0), _ep(ep_id="ep-2", score=1.0)] + dense = [_ep(ep_id="ep-1", score=0.5), _ep(ep_id="ep-2", score=0.4)] - assert result[0].item_type == "atomic_fact" - assert result[0].id == "fact-best" - - def test_fact_equal_to_parent_does_not_evict(self) -> None: - # Blend must strictly beat parent_lr; equal scores keep the episode. - episode = _episode_candidate(ep_id="ep-1") - fact = _fact_candidate(fact_id="fact-1", parent_episode_id="ep-1", score=0.7) - - result = _hierarchy_eviction_pass( - [episode], - {"ep-1": [fact]}, - ep_cosine={"ep-1": 0.7}, - ep_bm25={}, + result = heap_expand( + sparse=sparse, + dense=dense, + episode_to_facts={}, + top_k=2, + max_convergence_rounds=1, ) - assert result[0].item_type == "episode" + assert len(result) == 2 - def test_alpha_blends_parent_and_child(self) -> None: - # alpha=0.5 → score = 0.5*child_lr + 0.5*parent_lr. - episode = _episode_candidate(ep_id="ep-1") - fact = _fact_candidate(fact_id="fact-1", parent_episode_id="ep-1", score=0.9) + def test_alpha_blending(self) -> None: + """alpha=0.5 blends child and parent LR scores equally.""" + sparse = [_ep(ep_id="ep-1", score=2.0)] + dense = [_ep(ep_id="ep-1", score=0.5)] + facts = {"ep-1": [_fact(fact_id="f1", parent_episode_id="ep-1", score=0.95)]} - result = _hierarchy_eviction_pass( - [episode], - {"ep-1": [fact]}, - ep_cosine={"ep-1": 0.5}, - ep_bm25={}, + result = heap_expand( + sparse=sparse, + dense=dense, + episode_to_facts=facts, + top_k=2, alpha=0.5, ) - parent_lr = cosine_to_lr_score(0.5, 0.0) - child_lr = cosine_to_lr_score(0.9, 0.0) + child_lr = cosine_to_lr_score(0.95, 2.0) + parent_lr = cosine_to_lr_score(0.5, 2.0) expected = 0.5 * child_lr + 0.5 * parent_lr - assert result[0].item_type == "atomic_fact" - assert result[0].score == pytest.approx(expected) - - def test_bm25_raises_calibrated_score(self) -> None: - # BM25 is folded into both parent and child calibration (children - # inherit parent_bm25), so it does not change the intra-episode - # parent-vs-fact outcome at alpha=1 — it lifts the absolute LR score. - episode = _episode_candidate(ep_id="ep-1") - - without_bm25 = _hierarchy_eviction_pass( - [episode], {}, ep_cosine={"ep-1": 0.5}, ep_bm25={} - ) - with_bm25 = _hierarchy_eviction_pass( - [episode], {}, ep_cosine={"ep-1": 0.5}, ep_bm25={"ep-1": 50.0} - ) - - assert with_bm25[0].score > without_bm25[0].score - assert with_bm25[0].score == pytest.approx(cosine_to_lr_score(0.5, 50.0)) - - def test_facts_per_episode_window_caps_competition(self) -> None: - # A high-scoring fact beyond the window must not win. - episode = _episode_candidate(ep_id="ep-1") - in_window = _fact_candidate(fact_id="in", parent_episode_id="ep-1", score=0.55) - out_window = _fact_candidate( - fact_id="out", parent_episode_id="ep-1", score=0.99 - ) - - result = _hierarchy_eviction_pass( - [episode], - {"ep-1": [in_window, out_window]}, - ep_cosine={"ep-1": 0.6}, - ep_bm25={}, - facts_per_episode=1, - ) - - # Only ``in`` (0.55) is in the 1-fact window and it loses to 0.6 → - # episode wins; the 0.99 ``out`` fact is never considered. - assert result[0].item_type == "episode" - - -# ── _build_ep_to_fact_parents unit tests ──────────────────────────────── - - -class TestBuildEpToFactParents: - """Unit tests for the dual parent_id mapping builder.""" - - def test_entry_id_and_parent_id_both_collected(self) -> None: - """Post-1.5 episode with entry_id and parent_id both present.""" - ep = _episode_candidate(ep_id="ep-1", memcell_id="mc-1", entry_id="ep_entry_1") - - result = _build_ep_to_fact_parents([ep]) - - assert result == {"ep-1": ["ep_entry_1", "mc-1"]} - - def test_memcell_id_only_no_entry_id(self) -> None: - """Pre-1.5 episode: only parent_id (memcell_id), no entry_id.""" - ep = _episode_candidate(ep_id="ep-1", memcell_id="mc-1") - - result = _build_ep_to_fact_parents([ep]) - - assert result == {"ep-1": ["mc-1"]} - - def test_entry_id_equals_parent_id_no_duplicate(self) -> None: - """When entry_id == parent_id, only one value in the list.""" - ep = _episode_candidate(ep_id="ep-1", memcell_id="same_id", entry_id="same_id") - - result = _build_ep_to_fact_parents([ep]) - - assert result == {"ep-1": ["same_id"]} - - def test_missing_parent_id_skipped(self) -> None: - """Episode with no parent_id and no entry_id is excluded.""" - ep = Candidate( - id="ep-orphan", - score=0.5, - source="vector", - metadata={"owner_id": "u1"}, - ) - - result = _build_ep_to_fact_parents([ep]) - - assert result == {} - - def test_empty_string_parent_id_skipped(self) -> None: - """Empty string parent_id is filtered out.""" - ep = _episode_candidate(ep_id="ep-1", memcell_id="") - - result = _build_ep_to_fact_parents([ep]) - - assert result == {} - - def test_empty_string_entry_id_skipped(self) -> None: - """Empty string entry_id is filtered; parent_id still collected.""" - ep = _episode_candidate(ep_id="ep-1", memcell_id="mc-1", entry_id="") - - result = _build_ep_to_fact_parents([ep]) - - assert result == {"ep-1": ["mc-1"]} - - def test_multiple_episodes_independent(self) -> None: - ep_a = _episode_candidate( - ep_id="ep-a", memcell_id="mc-a", entry_id="ep_entry_a" - ) - ep_b = _episode_candidate(ep_id="ep-b", memcell_id="mc-b") - - result = _build_ep_to_fact_parents([ep_a, ep_b]) - - assert result == { - "ep-a": ["ep_entry_a", "mc-a"], - "ep-b": ["mc-b"], - } - - def test_empty_list_returns_empty_dict(self) -> None: - result = _build_ep_to_fact_parents([]) - assert result == {} - - -# ── hierarchy_retrieve_episodes integration-style unit tests ───────────── - - -class TestHierarchyRetrieveEpisodes: - """Integration-style unit tests with fully stubbed I/O. - - amaxsim_retrieve and rrf are exercised with real implementations but - all LanceDB / network calls are replaced by AsyncMock. - """ - - async def test_empty_sparse_dense_returns_empty_list(self) -> None: - fact_recaller, episode_recaller = _make_recallers() - - result = await hierarchy_retrieve_episodes( - query="test query", - sparse=[], - dense=[], - query_vector=[0.1, 0.2, 0.3], - fact_recaller=fact_recaller, - episode_recaller=episode_recaller, - where="owner_id = 'u1'", - top_k=10, - ) - - assert result == [] - - async def test_happy_path_episode_wins_no_nested_facts(self) -> None: - ep = _episode_candidate(ep_id="ep-1", score=0.8, memcell_id="mc-1") - - fact_recaller, episode_recaller = _make_recallers( - dense_facts=[], - fetched_episodes=[], - facts_for_episodes={}, - ) - - result = await hierarchy_retrieve_episodes( - query="test query", - sparse=[ep], - dense=[ep], - query_vector=[0.1, 0.2, 0.3], - fact_recaller=fact_recaller, - episode_recaller=episode_recaller, - where="owner_id = 'u1'", - top_k=10, - ) - - assert len(result) == 1 - episode_item = result[0] - assert episode_item.id == "ep-1" - assert episode_item.atomic_facts == [] - # Episode-win score is the LR-calibrated parent score (cosine 0.8, bm25 0.8). - assert episode_item.score == pytest.approx(cosine_to_lr_score(0.8, 0.8)) - - async def test_happy_path_fact_evicts_episode_nested_in_result(self) -> None: - ep = _episode_candidate(ep_id="ep-2", score=0.5, memcell_id="mc-2") - fact = _fact_candidate(fact_id="fact-2", parent_episode_id="ep-2", score=0.95) - - # No Layer-2 boost (dense_facts empty) → ep_cosine comes from the dense - # episode recall (0.5); sparse empty → parent_bm25 = 0.0. The Layer-4 - # fact (0.95) calibrates above the parent and evicts it. - fact_recaller, episode_recaller = _make_recallers( - dense_facts=[], - fetched_episodes=[], - facts_for_episodes={"ep-2": [fact]}, - ) - - result = await hierarchy_retrieve_episodes( - query="test query", - sparse=[], - dense=[ep], - query_vector=[0.1, 0.2, 0.3], - fact_recaller=fact_recaller, - episode_recaller=episode_recaller, - where="owner_id = 'u1'", - top_k=10, - ) - - assert len(result) == 1 - episode_item = result[0] - assert episode_item.atomic_facts != [] - nested_fact = episode_item.atomic_facts[0] - assert nested_fact.id == "fact-2" - # Evicted-fact score is its own LR-calibrated value (alpha default 1.0). - assert episode_item.score == pytest.approx(cosine_to_lr_score(0.95, 0.0)) - - async def test_min_score_filters_below_threshold(self) -> None: - ep = _episode_candidate(ep_id="ep-1", score=0.5, memcell_id="mc-1") - fact_recaller, episode_recaller = _make_recallers() - - produced = cosine_to_lr_score(0.5, 0.0) # episode-win score (sparse empty) - - result = await hierarchy_retrieve_episodes( - query="test query", - sparse=[], - dense=[ep], - query_vector=[0.1, 0.2, 0.3], - fact_recaller=fact_recaller, - episode_recaller=episode_recaller, - where="owner_id = 'u1'", - top_k=10, - min_score=produced + 0.05, - ) - - assert result == [] - - async def test_min_score_none_keeps_all(self) -> None: - ep = _episode_candidate(ep_id="ep-1", score=0.5, memcell_id="mc-1") - fact_recaller, episode_recaller = _make_recallers() - - result = await hierarchy_retrieve_episodes( - query="test query", - sparse=[], - dense=[ep], - query_vector=[0.1, 0.2, 0.3], - fact_recaller=fact_recaller, - episode_recaller=episode_recaller, - where="owner_id = 'u1'", - top_k=10, - min_score=None, - ) - - assert len(result) == 1 - assert result[0].id == "ep-1" + fact_items = [r for r in result if r.item_type == "atomic_fact"] + assert fact_items + assert fact_items[0].score == pytest.approx(expected) diff --git a/tests/unit/test_memory/test_search/test_manager.py b/tests/unit/test_memory/test_search/test_manager.py index 07b86e9f1..ff8817be3 100644 --- a/tests/unit/test_memory/test_search/test_manager.py +++ b/tests/unit/test_memory/test_search/test_manager.py @@ -6,7 +6,8 @@ * honour the ``owner_type`` hard partition, * run KEYWORD as sparse-only and leave ``atomic_facts`` empty, -* run VECTOR as dense-only (and refuse when no embedding is wired), +* run VECTOR via MaxSim (ANN atomic_facts -> max-pool -> resolve episodes) + and refuse when no embedding is wired, * let HYBRID run without an LLM by default; require LLM only when the caller sets ``enable_llm_rerank=True``, * refuse AGENTIC when reranker / LLM prerequisites are missing, @@ -330,7 +331,25 @@ async def test_user_keyword_filters_compile_pinned_owner() -> None: assert "owner_type = 'user'" in recaller.last_where -# ── VECTOR: requires embedding ──────────────────────────────────────── +def _atomic_fact_row(fid: str, *, parent_id: str, score: float) -> Candidate: + """Atomic-fact candidate emitted by ``AtomicFactRecaller.dense_recall``.""" + return Candidate( + id=fid, + score=score, + source="vector", + metadata={ + "owner_id": "alice", + "owner_type": "user", + "session_id": "sess_a", + "timestamp": _ts(), + "sender_ids": ["alice"], + "parent_id": parent_id, + "fact": f"fact {fid}", + }, + ) + + +# ── VECTOR (MaxSim atomic) ──────────────────────────────────────────── async def test_vector_method_requires_embedding() -> None: @@ -339,10 +358,13 @@ async def test_vector_method_requires_embedding() -> None: await mgr.search(_user_req(method=SearchMethod.VECTOR)) -async def test_vector_method_runs_dense_only_with_embedding() -> None: +async def test_vector_method_returns_episodes_via_maxsim() -> None: mgr = _build_manager( episode_sparse=[_episode_row("should_not_appear")], episode_dense=[_episode_row("ep_dense")], + atomic_fact_dense=[ + _atomic_fact_row("f1", parent_id="ep_dense", score=0.85), + ], embedding=_StubEmbedding(), ) resp = await mgr.search(_user_req(method=SearchMethod.VECTOR)) @@ -352,8 +374,12 @@ async def test_vector_method_runs_dense_only_with_embedding() -> None: async def test_vector_radius_filter_drops_below_threshold() -> None: mgr = _build_manager( episode_dense=[ - _episode_row("ep_low", score=0.3), - _episode_row("ep_high", score=0.9), + _episode_row("ep_low"), + _episode_row("ep_high"), + ], + atomic_fact_dense=[ + _atomic_fact_row("f_low", parent_id="ep_low", score=0.3), + _atomic_fact_row("f_high", parent_id="ep_high", score=0.9), ], embedding=_StubEmbedding(), ) @@ -369,22 +395,32 @@ async def test_unlimited_mode_applies_default_radius_for_vector() -> None: """ mgr = _build_manager( episode_dense=[ - _episode_row("ep_low", score=0.3), # below default 0.5 → dropped - _episode_row("ep_mid", score=0.55), # above default → kept - _episode_row("ep_high", score=0.9), + _episode_row("ep_low"), + _episode_row("ep_mid"), + _episode_row("ep_high"), + ], + atomic_fact_dense=[ + _atomic_fact_row("f_low", parent_id="ep_low", score=0.3), # below 0.5 + _atomic_fact_row("f_mid", parent_id="ep_mid", score=0.55), # above 0.5 + _atomic_fact_row("f_high", parent_id="ep_high", score=0.9), ], embedding=_StubEmbedding(), ) resp = await mgr.search(_user_req(method=SearchMethod.VECTOR, top_k=-1)) - assert [e.id for e in resp.data.episodes] == ["ep_mid", "ep_high"] + # Ordered by max-pooled fact score descending. + assert [e.id for e in resp.data.episodes] == ["ep_high", "ep_mid"] async def test_unlimited_mode_explicit_radius_overrides_default() -> None: """Caller-supplied radius (even ``0.0``) wins over the unlimited default.""" mgr = _build_manager( episode_dense=[ - _episode_row("ep_low", score=0.2), - _episode_row("ep_high", score=0.9), + _episode_row("ep_low"), + _episode_row("ep_high"), + ], + atomic_fact_dense=[ + _atomic_fact_row("f_low", parent_id="ep_low", score=0.2), + _atomic_fact_row("f_high", parent_id="ep_high", score=0.9), ], embedding=_StubEmbedding(), ) @@ -397,49 +433,23 @@ async def test_normal_mode_keeps_full_pool_when_no_radius() -> None: """``top_k > 0`` without a radius applies no threshold — truncation handles tail.""" mgr = _build_manager( episode_dense=[ - _episode_row("ep_low", score=0.2), - _episode_row("ep_high", score=0.9), + _episode_row("ep_low"), + _episode_row("ep_high"), + ], + atomic_fact_dense=[ + _atomic_fact_row("f_low", parent_id="ep_low", score=0.2), + _atomic_fact_row("f_high", parent_id="ep_high", score=0.9), ], embedding=_StubEmbedding(), ) resp = await mgr.search(_user_req(method=SearchMethod.VECTOR, top_k=10)) - # No radius default in normal mode → both kept. + # No radius default in normal mode -> both kept. assert {e.id for e in resp.data.episodes} == {"ep_low", "ep_high"} -# ── VECTOR + maxsim_atomic strategy ───────────────────────────────────── - - -def _atomic_fact_row(fid: str, *, parent_id: str, score: float) -> Candidate: - """Atomic-fact candidate emitted by ``AtomicFactRecaller.dense_recall``.""" - return Candidate( - id=fid, - score=score, - source="vector", - metadata={ - "owner_id": "alice", - "owner_type": "user", - "session_id": "sess_a", - "timestamp": _ts(), - "sender_ids": ["alice"], - "parent_id": parent_id, - "fact": f"fact {fid}", - }, - ) - - -async def test_vector_maxsim_atomic_max_pools_facts_to_episodes( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``vector_strategy=maxsim_atomic`` should ANN atomic_facts → max-pool by - episode entry_id → resolve to episode, ordering episodes by the - per-episode maximum fact score.""" - from everos.config.settings import load_settings - - monkeypatch.setenv("EVEROS_SEARCH__VECTOR_STRATEGY", "maxsim_atomic") - load_settings.cache_clear() - # Two episodes; each has two atomic facts under it. The max fact score - # per memcell is what should end up as the episode's score. +async def test_vector_maxsim_max_pools_facts_to_episodes() -> None: + """ANN atomic_facts -> max-pool by episode entry_id -> resolve to + episode, ordering episodes by the per-episode maximum fact score.""" mgr = _build_manager( episode_dense=[ _episode_row("ep_A", memcell_id="mc_A"), @@ -460,14 +470,8 @@ async def test_vector_maxsim_atomic_max_pools_facts_to_episodes( assert eps[1].score == pytest.approx(0.75) -async def test_vector_maxsim_atomic_returns_empty_when_no_facts( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """No fact recall → no memcells to score → empty episode list.""" - from everos.config.settings import load_settings - - monkeypatch.setenv("EVEROS_SEARCH__VECTOR_STRATEGY", "maxsim_atomic") - load_settings.cache_clear() +async def test_vector_returns_empty_when_no_facts() -> None: + """No fact recall -> no episodes to score -> empty episode list.""" mgr = _build_manager( episode_dense=[_episode_row("ep_A", memcell_id="mc_A")], atomic_fact_dense=[], @@ -503,9 +507,9 @@ async def test_hybrid_requires_llm_when_enable_llm_rerank_true() -> None: async def test_user_hybrid_episode_fuses_and_evicts_facts() -> None: - """HYBRID episode path: hierarchy pipeline (RRF -> MaxSim -> merge -> eviction). + """HYBRID episode path: heap-expand pipeline (RRF -> LR -> expansion). - ep_1 has a fact scoring higher than the RRF score -> fact evicts episode. + ep_1 has a fact scoring higher than its LR score -> fact evicts episode. ep_2 has no facts -> episode emitted as-is. """ ep1 = _episode_row("ep_1", score=0.8, memcell_id="mc_1") diff --git a/tests/unit/test_memory/test_search/test_recall_episode.py b/tests/unit/test_memory/test_search/test_recall_episode.py index f579e726c..4549322d8 100644 --- a/tests/unit/test_memory/test_search/test_recall_episode.py +++ b/tests/unit/test_memory/test_search/test_recall_episode.py @@ -200,3 +200,179 @@ async def test_fetch_by_entry_ids_empty_input_returns_empty( """Empty entry_ids list short-circuits without querying.""" result = await recaller.fetch_by_entry_ids([], "owner_id = 'alice'") assert result == [] + + +def _mock_bm25_table(rows: list[dict[str, Any]]) -> MagicMock: + tbl = MagicMock() + chain = tbl.query.return_value.nearest_to_text.return_value + chain.where.return_value.limit.return_value.to_list = AsyncMock(return_value=rows) + return tbl + + +def _mock_ann_table(rows: list[dict[str, Any]]) -> MagicMock: + tbl = MagicMock() + q = tbl.query.return_value + col = q.nearest_to.return_value.column.return_value + chain = col.distance_type.return_value + chain.where.return_value.limit.return_value.to_list = AsyncMock(return_value=rows) + return tbl + + +async def test_sparse_recall_as_child_injects_parent_id( + recaller: EpisodeRecaller, +) -> None: + """sparse_recall_as_child adds parent_id=entry_id to each candidate's metadata.""" + rows = [ + {**_make_row("ep_1", "mc_1", entry_id="entry_1"), "_score": 1.0}, + ] + with patch( + "everos.memory.search.recall.episode.get_table", + new_callable=AsyncMock, + return_value=_mock_bm25_table(rows), + ): + result = await recaller.sparse_recall_as_child( + "hello", "owner_id = 'alice'", limit=10 + ) + + assert len(result) == 1 + assert result[0].metadata["parent_id"] == "entry_1" + + +async def test_sparse_recall_as_child_falls_back_to_id_when_no_entry_id( + recaller: EpisodeRecaller, +) -> None: + """When entry_id is absent in metadata, parent_id falls back to candidate id.""" + row = { + "id": "ep_2", + "owner_id": "alice", + "owner_type": "user", + "session_id": "s", + "timestamp": 1, + "sender_ids": [], + "subject": "", + "summary": "", + "episode": "body", + "parent_id": "mc_x", + "_score": 0.5, + } + with patch( + "everos.memory.search.recall.episode.get_table", + new_callable=AsyncMock, + return_value=_mock_bm25_table([row]), + ): + result = await recaller.sparse_recall_as_child( + "hello", "owner_id = 'alice'", limit=10 + ) + + assert len(result) == 1 + cand = result[0] + assert cand.metadata["parent_id"] == cand.id + + +async def test_sparse_recall_as_child_empty_query_returns_empty( + recaller: EpisodeRecaller, +) -> None: + """Empty query token list short-circuits; no table call needed.""" + recaller._deps.tokenizer.tokenize.return_value = [] + result = await recaller.sparse_recall_as_child("", "owner_id = 'alice'", limit=10) + assert result == [] + + +async def test_dense_recall_as_child_injects_parent_id( + recaller: EpisodeRecaller, +) -> None: + """dense_recall_as_child adds parent_id=entry_id to each candidate's metadata.""" + rows = [ + {**_make_row("ep_3", "mc_3", entry_id="entry_3"), "_distance": 0.1}, + ] + with patch( + "everos.memory.search.recall.episode.get_table", + new_callable=AsyncMock, + return_value=_mock_ann_table(rows), + ): + result = await recaller.dense_recall_as_child( + [0.1] * 1024, "owner_id = 'alice'", limit=10 + ) + + assert len(result) == 1 + assert result[0].metadata["parent_id"] == "entry_3" + + +async def test_dense_recall_as_child_empty_vector_returns_empty( + recaller: EpisodeRecaller, +) -> None: + """Empty vector short-circuits without querying.""" + result = await recaller.dense_recall_as_child([], "owner_id = 'alice'", limit=10) + assert result == [] + + +def _mock_subject_ann_table(rows: list[dict[str, Any]]) -> MagicMock: + """Mock for subject_vector ANN chain.""" + tbl = MagicMock() + q = tbl.query.return_value + col = q.nearest_to.return_value.column.return_value + dist = col.distance_type.return_value + dist.where.return_value.limit.return_value.to_list = AsyncMock( + return_value=rows, + ) + return tbl + + +async def test_dense_recall_subject_returns_subject_vector_source( + recaller: EpisodeRecaller, +) -> None: + """dense_recall_subject returns source='vector'.""" + rows = [ + {**_make_row("ep_s1", "mc_s1", entry_id="entry_s1"), "_distance": 0.2}, + ] + with patch( + "everos.memory.search.recall.episode.get_table", + new_callable=AsyncMock, + return_value=_mock_subject_ann_table(rows), + ): + result = await recaller.dense_recall_subject( + [0.1] * 1024, "owner_id = 'alice'", limit=10 + ) + + assert len(result) == 1 + assert result[0].source == "vector" + assert result[0].score == pytest.approx(0.8) + + +async def test_dense_recall_subject_empty_vector_returns_empty( + recaller: EpisodeRecaller, +) -> None: + """Empty vector short-circuits without querying.""" + result = await recaller.dense_recall_subject([], "owner_id = 'alice'", limit=10) + assert result == [] + + +async def test_dense_recall_subject_as_child_injects_parent_id( + recaller: EpisodeRecaller, +) -> None: + """dense_recall_subject_as_child adds parent_id=entry_id to metadata.""" + rows = [ + {**_make_row("ep_s2", "mc_s2", entry_id="entry_s2"), "_distance": 0.15}, + ] + with patch( + "everos.memory.search.recall.episode.get_table", + new_callable=AsyncMock, + return_value=_mock_subject_ann_table(rows), + ): + result = await recaller.dense_recall_subject_as_child( + [0.1] * 1024, "owner_id = 'alice'", limit=10 + ) + + assert len(result) == 1 + assert result[0].metadata["parent_id"] == "entry_s2" + assert result[0].source == "vector" + + +async def test_dense_recall_subject_as_child_empty_vector_returns_empty( + recaller: EpisodeRecaller, +) -> None: + """Empty vector short-circuits without querying.""" + result = await recaller.dense_recall_subject_as_child( + [], "owner_id = 'alice'", limit=10 + ) + assert result == [] diff --git a/tests/unit/test_memory/test_strategies/test_registration.py b/tests/unit/test_memory/test_strategies/test_registration.py index 717e0912d..004d73c2a 100644 --- a/tests/unit/test_memory/test_strategies/test_registration.py +++ b/tests/unit/test_memory/test_strategies/test_registration.py @@ -46,7 +46,7 @@ async def test_get_engine_registers_all_strategies( monkeypatch.setattr(svc, "_ome_engine", None, raising=False) engine = svc._get_engine() - names = {m.name for m in engine._registry.all()} # noqa: SLF001 — test introspection + names = {m.name for m in engine._registry.all()} assert names == { "extract_atomic_facts", "extract_foresight", diff --git a/tests/unit/test_memory/test_strategies/test_strategy_to_handler_contract.py b/tests/unit/test_memory/test_strategies/test_strategy_to_handler_contract.py index e0a68dfd1..d83caec62 100644 --- a/tests/unit/test_memory/test_strategies/test_strategy_to_handler_contract.py +++ b/tests/unit/test_memory/test_strategies/test_strategy_to_handler_contract.py @@ -111,9 +111,9 @@ async def _build_row_from_md( pe = ParsedEntry( entry_id=entry.id, structured=structured, - content_sha256=handler._content_sha256(structured), # noqa: SLF001 + content_sha256=handler._content_sha256(structured), ) - return await handler._build_row( # noqa: SLF001 + return await handler._build_row( owner_id=owner_id, owner_type=owner_type, md_path=rel, diff --git a/tests/unit/test_memory/test_strategies/test_trigger_skill_clustering.py b/tests/unit/test_memory/test_strategies/test_trigger_skill_clustering.py index 28560560b..c04a7e4c0 100644 --- a/tests/unit/test_memory/test_strategies/test_trigger_skill_clustering.py +++ b/tests/unit/test_memory/test_strategies/test_trigger_skill_clustering.py @@ -116,7 +116,7 @@ async def test_creates_new_cluster_when_no_existing( await trigger_skill_clustering(_event(), ctx) # cluster_by_llm called with the size-1 new cluster + empty existing. - args, kwargs = mock_cluster.call_args + args, _kwargs = mock_cluster.call_args new_cluster, existing = args assert isinstance(new_cluster, AlgoCluster) assert new_cluster.id == "cl_newxxxx0001" diff --git a/tests/unit/test_service/test_knowledge_search_degradation.py b/tests/unit/test_service/test_knowledge_search_degradation.py index 4b20e20a5..2c0eec3c9 100644 --- a/tests/unit/test_service/test_knowledge_search_degradation.py +++ b/tests/unit/test_service/test_knowledge_search_degradation.py @@ -22,7 +22,7 @@ async def test_search_without_embedding_raises() -> None: with ( patch(f"{_MOD}._get_embedding", return_value=None), - pytest.raises(ConfigurationError, match="[Ee]mbedding"), + pytest.raises(ConfigurationError, match=r"[Ee]mbedding"), ): await search_knowledge(query="test", method="vector") @@ -36,6 +36,6 @@ async def test_search_without_reranker_raises() -> None: with ( patch(f"{_MOD}._get_embedding", return_value=mock_embedder), patch(f"{_MOD}._get_reranker", return_value=None), - pytest.raises(ConfigurationError, match="[Rr]erank"), + pytest.raises(ConfigurationError, match=r"[Rr]erank"), ): await search_knowledge(query="test", method="keyword") diff --git a/uv.lock b/uv.lock index 80558ad24..0af649197 100644 --- a/uv.lock +++ b/uv.lock @@ -13,16 +13,16 @@ wheels = [ [[package]] name = "alembic" -version = "1.18.4" +version = "1.18.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, ] [[package]] @@ -58,14 +58,14 @@ wheels = [ [[package]] name = "apscheduler" -version = "3.11.2" +version = "3.11.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzlocal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30", size = 66024, upload-time = "2026-06-28T19:39:20.982Z" }, ] [[package]] @@ -470,15 +470,15 @@ wheels = [ [[package]] name = "everalgo-clustering" -version = "0.2.0" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "everalgo-core" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/23/aca68fa184cae216f55e7c6b4985001323e5247d2bd04949e6f16399fb54/everalgo_clustering-0.2.0.tar.gz", hash = "sha256:9abf39da845d954a46db675ba9aeaab6702d258799a082b910e806dba3c6cdb4", size = 13316, upload-time = "2026-05-27T12:11:08.521Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/65/7be2e83566546a1e6eae1ff78aaff5aa30c0b2a230dc38a27b7c3d87eada/everalgo_clustering-0.2.1.tar.gz", hash = "sha256:30fd973f68520e778d3d1bd659198c59f49cf602e9c24b3962297d7c8293ab7e", size = 13446, upload-time = "2026-06-15T06:14:56.047Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/d7/cee385939974b825f8a208af073782fa11effc8069f4b99137cdbe520c8f/everalgo_clustering-0.2.0-py3-none-any.whl", hash = "sha256:368f784452617c90e15e2a027344d11668801ad6456aa14404cfa603f1f77ef8", size = 12073, upload-time = "2026-05-27T12:11:07.632Z" }, + { url = "https://files.pythonhosted.org/packages/3c/41/76a14d1aa18a164eef0dd25b8fe43b2aca7a606c33c6af58ec9806a13762/everalgo_clustering-0.2.1-py3-none-any.whl", hash = "sha256:40d2e42dd6472a36126b012df5be0855ea1a921503c86329fdd5d902f28abc04", size = 12085, upload-time = "2026-06-15T06:14:55.224Z" }, ] [[package]] @@ -560,7 +560,7 @@ wheels = [ [[package]] name = "everos" -version = "1.1.0" +version = "1.1.1" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -580,7 +580,6 @@ dependencies = [ { name = "prometheus-client" }, { name = "pydantic" }, { name = "pydantic-settings" }, - { name = "python-dotenv" }, { name = "python-multipart" }, { name = "pyyaml" }, { name = "sqlmodel" }, @@ -623,14 +622,13 @@ requires-dist = [ { name = "everalgo-user-memory", specifier = "==0.3.1" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "greenlet", specifier = ">=3.0" }, - { name = "jieba", specifier = "==0.42.1" }, + { name = "jieba", specifier = ">=0.42.1,<1.0" }, { name = "lancedb", specifier = ">=0.13.0" }, { name = "openai", specifier = ">=1.0.0" }, { name = "portalocker", specifier = ">=2.8.2" }, { name = "prometheus-client", specifier = ">=0.20.0" }, { name = "pydantic", specifier = ">=2.7.1" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-multipart", specifier = ">=0.0.7" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "sqlmodel", specifier = ">=0.0.22" }, @@ -667,7 +665,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.1" +version = "0.139.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -676,9 +674,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, ] [[package]] @@ -692,49 +690,69 @@ wheels = [ [[package]] name = "greenlet" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, - { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, - { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, - { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, - { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, - { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, - { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, - { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" }, - { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, - { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, - { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, - { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, - { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, - { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, ] [[package]] @@ -830,31 +848,38 @@ wheels = [ [[package]] name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] [[package]] @@ -1053,19 +1078,19 @@ wheels = [ [[package]] name = "lance-namespace" -version = "0.7.6" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lance-namespace-urllib3-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/da/134670003173881bed44af656badffd91e0b2e0232c083eeacc5923d7335/lance_namespace-0.7.6.tar.gz", hash = "sha256:4e12094005d105ef1b44346c9d7feda4a0f733b127dab90c1a5ffbf7cd433770", size = 10686, upload-time = "2026-05-05T18:26:38.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/81/4cf8d0412e1f37b2bfa70d0aeb9c7ae4ab73607534e44d60b55efb485306/lance_namespace-0.9.0.tar.gz", hash = "sha256:f738b641cc615b17323baa4eb47900f184688739ee3d2ea9fe39396b9588e53d", size = 11637, upload-time = "2026-07-01T07:42:41.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/88/44463a5f41f7077b2ea641f2afded72eaceb6a6a1b4a55c11b22318fed74/lance_namespace-0.7.6-py3-none-any.whl", hash = "sha256:c94a1b8a6aab127e55a20cbf44d927ae3a9b7d435656d2130dccf84ccf7c9999", size = 12519, upload-time = "2026-05-05T18:26:36.425Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/f38747c9610ade83dd9a99a0470b9432b6f21ce4e2bb5524edbe66f626fd/lance_namespace-0.9.0-py3-none-any.whl", hash = "sha256:f785ff10927e4ce0db69986576670fedd37f8a33521e8a4630c6be22db8061b2", size = 13501, upload-time = "2026-07-01T07:42:39.372Z" }, ] [[package]] name = "lance-namespace-urllib3-client" -version = "0.7.6" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -1073,14 +1098,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/44/024aae184c08b3800482cd9b832d534249e25de145af732d4e4c8dff38a8/lance_namespace_urllib3_client-0.7.6.tar.gz", hash = "sha256:15ae7f0d8d56fa34d837f7f6ec5c80a327a905e89ccfed05f7b409d6fe704cdf", size = 195551, upload-time = "2026-05-05T18:26:37.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/c3/32d0e2618549ace857c80a457e5915ef3e1145661baff876c8a5ec27be5b/lance_namespace_urllib3_client-0.9.0.tar.gz", hash = "sha256:cf796fa5307fa4dde91fe4bec2af28b90ba79191852d4394e8fe44276538e40f", size = 235805, upload-time = "2026-07-01T07:42:42.563Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/50/60c983cc8180772c82370dfad2104b7e788aaacc3bf9a84e8b42bb1ae6a7/lance_namespace_urllib3_client-0.7.6-py3-none-any.whl", hash = "sha256:fb884d8afff8af3aae04a3270624694a189d7ea79225dd349e6c555a1a1d6b52", size = 324603, upload-time = "2026-05-05T18:26:39.718Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ab/c8754da0a1efc817f8480100cfe12b7e04034df834759de8ecf02beff3cc/lance_namespace_urllib3_client-0.9.0-py3-none-any.whl", hash = "sha256:be819c8cffb1e460a3a504dbf52d1ca009560a48e7202b8c4279998e4adf9fe4", size = 405586, upload-time = "2026-07-01T07:42:40.503Z" }, ] [[package]] name = "lancedb" -version = "0.30.2" +version = "0.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -1092,12 +1117,10 @@ dependencies = [ { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/87/67b23006663be175c396ae8f7c6ac98bfa4728de5b5583016b8b8c54eb14/lancedb-0.30.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:3dd8cb9e2e25efb32c088b24b3fbc57f3f24a636f4b8ad4b287b1eb52f6b5075", size = 41720461, upload-time = "2026-03-31T22:42:32.853Z" }, - { url = "https://files.pythonhosted.org/packages/78/68/b3b5f638f8de91de75751414114690cae9c294dc79d9ab2602f4562ed9df/lancedb-0.30.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f083d50b257f645bd5c4b295d693648ffb37640ce1e9d72f55041b1382f0dbd6", size = 43626135, upload-time = "2026-03-31T22:50:28.577Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d1/ea8b74a8b56dd4925cc9cb9cc23c7d9675708a7f6b33d22136dc7bb34dbc/lancedb-0.30.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aef5538db9cd82af79c90831035b4d67e9aa182ef73095a1b919caddf9bb7a5", size = 46619289, upload-time = "2026-03-31T22:55:02.242Z" }, - { url = "https://files.pythonhosted.org/packages/74/4b/5bfeacf948cfc3452b286a792dcbbfaf04649ef0820e1d3790d47bf5527e/lancedb-0.30.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8b161cb1da04ae6ad45afe10093cfe4107821d93e7712b50200c435d6f4c8a20", size = 43641193, upload-time = "2026-03-31T22:51:13.63Z" }, - { url = "https://files.pythonhosted.org/packages/28/4c/a51af0ce1d18fd86afa3e8538a81abf5523d24632abe7665ce6795b8009d/lancedb-0.30.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7fabc0f57944fd79ddef62ed8cf4df770654b172b1ad1019a999304fed3169f3", size = 46665361, upload-time = "2026-03-31T22:54:20.282Z" }, - { url = "https://files.pythonhosted.org/packages/88/d0/7e44e8143ac2dae8979ba882cc33d4af7b8da4741fb0361497e69b4a4379/lancedb-0.30.2-cp39-abi3-win_amd64.whl", hash = "sha256:531da53002c1c6fda829afccc8ced3056ef58eb036f09ddb2b94a06877ecc66c", size = 50940681, upload-time = "2026-03-31T23:25:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/5262b9aa593f790757163c0165ab0da1dda054758901bea7e4f02c9cb633/lancedb-0.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c462f2e6f933cad659fd0179394eaab578acbc9151fe2ef41bc29b36ecca5058", size = 52654213, upload-time = "2026-07-02T17:13:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/69/99/05ea0d32229ebea695193ff20c15d6ecae25785ad82a9d4723d98832a284/lancedb-0.34.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:48829e88e708947d0520454ab9e4f8efa35f3e3626469eadd3a6e061b89cb223", size = 55434501, upload-time = "2026-07-02T17:13:34.81Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4e/4325c13d5afa93c466428a5a0f168ad4d96f5eb4a77bbe7c5100d39c9897/lancedb-0.34.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:05ba8a5b58e064edfbe5be71b1abf2e411b4eaf295d1a173dcb1a55c5bfb5285", size = 58659359, upload-time = "2026-07-02T17:13:38.424Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/8ca165f1386caf6c4d1c515afd52f345b66432264eecfdfb7fd33eefd9af/lancedb-0.34.0-cp39-abi3-win_amd64.whl", hash = "sha256:51cbc11808f9e3332819b9367c975b3a888541447a8e7bea09c57c852a279153", size = 63530726, upload-time = "2026-07-02T17:13:41.612Z" }, ] [[package]] @@ -1248,63 +1271,53 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, - { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, - { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, - { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, - { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, - { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, - { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, - { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, - { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, ] [[package]] @@ -1682,16 +1695,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.0" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -1844,18 +1857,21 @@ wheels = [ [[package]] name = "pywin32" -version = "311" +version = "312" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, ] [[package]] @@ -2083,62 +2099,57 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.49" +version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, - { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, - { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, - { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, - { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, - { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, - { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, - { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, - { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, - { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, - { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, - { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [[package]] name = "sqlmodel" -version = "0.0.38" +version = "0.0.39" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ee/22a0559283c3cf6048678e787ed5d4959dcd00dedd8ba4567eeae684eeb1/sqlmodel-0.0.39.tar.gz", hash = "sha256:23d8e50a8d8ee936032ed79c55023a5d618dd6bc3c510bbf4909d1a7a605a570", size = 91057, upload-time = "2026-06-25T13:01:38.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7d/b9813a582d4eb310be35e1fc7dfaae71207d7b62e9e53be314ebd251b53b/sqlmodel-0.0.39-py3-none-any.whl", hash = "sha256:90ebe92ce5cc11d7fff8dc7cb594790a102333c8fe7c14865254f6fc5c939795", size = 29680, upload-time = "2026-06-25T13:01:37.494Z" }, ] [[package]] @@ -2157,29 +2168,29 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" 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/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] name = "structlog" -version = "25.5.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, ] [[package]] name = "textual" -version = "8.2.7" +version = "8.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, @@ -2189,9 +2200,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, ] [[package]] @@ -2276,17 +2287,17 @@ wheels = [ [[package]] name = "typer" -version = "0.25.1" +version = "0.26.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, ] [[package]] @@ -2321,14 +2332,14 @@ wheels = [ [[package]] name = "tzlocal" -version = "5.3.1" +version = "5.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +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/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, + { 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]] @@ -2351,15 +2362,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.46.0" +version = "0.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/41/06cce5dbb9f77591512957710ac709e60b12e6216a2f2d0d607fd49706e8/uvicorn-0.50.0.tar.gz", hash = "sha256:0c92e1bc2259cb7faa4fcef774a5966588f2e88542744550b66799fba10b76f1", size = 93257, upload-time = "2026-07-04T05:03:26.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/eb70620ca2bf8213603d5c731460687c49fee38b0072f0b4a637781f0a53/uvicorn-0.50.0-py3-none-any.whl", hash = "sha256:05f0eb19edf38208f79f43df8a63081b48df31b0cd1e5997be957a4dc97d1b19", size = 72716, upload-time = "2026-07-04T05:03:24.848Z" }, ] [package.optional-dependencies] @@ -2446,72 +2457,88 @@ wheels = [ [[package]] name = "watchfiles" -version = "1.1.1" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] [[package]]