1 Commits

Author SHA1 Message Date
coreci-bot bbf80ce318 ship: v0.2 P0 pre-execution (#9) 2026-09-27 10:58:36 +00:00
86 changed files with 238 additions and 8464 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"phase": 5,
"stage": "complete",
"phase": 0,
"stage": "mvp_ux_check",
"milestone": "v0.2",
"phase_role": "final",
"phase_role": "pre_execution",
"attempts": 0,
"updated_at": "2026-09-27T16:30:00Z"
"updated_at": "2026-09-27T11:35:00Z"
}
+1 -2
View File
@@ -215,5 +215,4 @@ The plan is sound with fixable gaps. The evidence base is unusually strong for a
| G-010 | Meta | Pre-agreed contingency lever: if P2/P4 slip beyond one extension cycle, Deep Report (depth 3) defers to v0.3 and the MVP ships Rapid Answer end-to-end; pulling the lever requires owner sign-off at a gate. | 0.65 |
### Escalations
- [G-009] Axis 2 — Owner ratification required at the P5 gate (D-018 halt): the v0.2 MVP demonstrates the **live core research loop only**; the remaining approved-prototype surfaces (Library, Settings persistence, Suggestions, Refine, graph/inbox previews, profile) remain mock-bound until v0.3+, and the live-mode gate demo requires a local backend + keys (or runs in mock mode) since D-019 forbids deploys. This redefines what "MVP done" looks like at the demo and must not be discovered by the owner at the gate — it must be ratified in advance. Engineering-side handling is bound in G-002/G-003; the ratification itself is unresolved until the owner records it.
- **RESOLVED 2026-09-27: RATIFIED.** Owner approval recorded via /ci-run 'APPROVED' at the v0.2.5 gate (decision commit 4721c2a), following the owner-directed UAT staging at https://research.coreci.dev. Live-mode scope = core research loop only is the accepted MVP boundary; mock-bound surfaces move to v0.3+.
- [G-009] Axis 2 — Owner ratification required at the P5 gate (D-018 halt): the v0.2 MVP demonstrates the **live core research loop only**; the remaining approved-prototype surfaces (Library, Settings persistence, Suggestions, Refine, graph/inbox previews, profile) remain mock-bound until v0.3+, and the live-mode gate demo requires a local backend + keys (or runs in mock mode) since D-019 forbids deploys. This redefines what "MVP done" looks like at the demo and must not be discovered by the owner at the gate — it must be ratified in advance. Engineering-side handling is bound in G-002/G-003; the ratification itself is unresolved until the owner records it.
+1 -1
View File
@@ -40,7 +40,7 @@ reason: "v0.2 is the MVP: FastAPI backend + engine + adapters + trust loop (back
### security-engineer (phase-specific: P2)
- **Domain**: security
- **Active**: false (P2 shipped — removed per phase-specific policy)
- **Active**: true (P2 only)
- **Phase-specific**: true
- **Frameworks**: httpx, pytest, STRIDE analysis
- **Constraints**: secrets resolve from env-file scopes only (OLLAMA_API_KEY, TAVILY_API_KEY, GITEA_TOKEN via .ciagent/.env.secrets) — never shell env, never logged, never in git; liveness verification and trust-loop code must not leak keys in errors or traces; SSRF guard for fetched URLs (private-address refusal); authority tiering must not phone home
+63 -26
View File
@@ -1,44 +1,81 @@
# PLAN — Milestone v0.2 Phase 5 (FINAL): Review + Audit + Ship
# PLAN — Milestone v0.2 Phase 1: Methodology Framework + Adapter Layer
---
phase: 5
phase: 1
milestone: v0.2
status: executed
status: planned
must_haves:
- "Multi-persona full-milestone review: 4 P0 + 15 P1 + 15 P2 found; all 4 P0s fixed in-phase with attack-proofs (A1 tautological anchoring → real writer-quote extraction; C2 gate denominator → cited-URL scoped; C1 zombie runs → shape-validated plans + catch-all FAILED persist; S1 fetch SSRF → per-hop re-guarding); 6 cheap P1s fixed (approve-with-plan, FAILED status, length caps, probe wiring, README env-var + adapter honesty)"
- "Audit: PASS-WITH-NOTES — reconstruction PASS (all REQ-01..34 covered across v0.2.0–v0.2.4), secrets clean (zero key values in history), branch hygiene exact; critical notes fixed in-phase (REQUIREMENTS traceability un-staled, P5 PLAN written, ROADMAP P5 status, security-engineer persona deactivated per its removal note)"
- "Milestone ship: v0.2.5 tagged as the MVP release candidate — the pipeline then HALTS at the merge_to_main owner approval gate (D-018)"
- "G-009 escalation presented at the gate: the owner ratifies that live mode covers the core research loop only (G-002); remaining prototype surfaces stay mock-backed to v0.3+"
- "backend/ uv project scaffolds with one command (uv sync) and gates green from the first commit (ruff, mypy strict, pytest)"
- "12-dial typed schema with safe defaults resolves; every methodology = named dial bundle + planner/writer templates + output shape; registry resolves by slug (rapid-answer, deep-report)"
- "Resolved dial values persist to run config and are auditable (REQ-04) — schema-level contract proven by test"
- "Adapter contracts (model/search/storage/memory/secrets) typed; local implementations + fake model/search adapters enable zero-API-key engine tests (REQ-31)"
- "Probe/warning CONTRACTS typed and proven with fakes: capability-probe result (context cap min(model claim, 8192), embedding batch 8) and structured-output warning surfaces [REQ-07/REQ-12 contracts; LIVE endpoint probing executes in P2 per grill G-001]"
- "Model adapter: Ollama cloud-first with keyless localhost fallback (REQ-08); search: Tavily default + DDG keyless fallback (REQ-09); storage: local FS Markdown/JSON (REQ-10); memory stubbed off, secrets env-backed (REQ-11)"
- "Structured-output weakness warning CONTRACT (REQ-12): typed warning surface + graceful-degrade path proven with fakes"
personas:
- data-engineer
- backend-engineer
- ci-lead-developer
requirements: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-17, REQ-18, REQ-19, REQ-20, REQ-21, REQ-22, REQ-23, REQ-24, REQ-25, REQ-26, REQ-31, REQ-32, REQ-33, REQ-34]
requirements: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-08, REQ-09, REQ-10, REQ-11, REQ-31]
---
## User-Facing Surface
The MVP release candidate: the approved v0.1 prototype frontend (Ask → Choose → Plan & Run → Reading Room, all 8 cards, shell, library, settings) now backed by a real FastAPI research engine in live mode — methodology registry (Rapid Answer + Deep Report), 12-dial framework, asyncio state machine with durable pause/cancel/resume, visited-only evidence store, attack-proven trust loop (quote anchoring, SSRF-guarded liveness, citation ledger, ≥99% export gate with zero-unanchored requirement), SSE live trace, adversarial suite (14 attacks), E2E happy path on the real stack. Releases v0.2.0–v0.2.5.
No new UI in P1 — the frontend is untouched (mock-backed, still v0.1-complete). User-facing surface for P1 is the **backend package itself**: `uv sync && uv run uvicorn researchx.api.app:app` boots a healthless dev server skeleton (routes land in P3), and the methodology registry + adapters are exercisable from tests and a documented README quickstart section. The deliverable P1 surface ships to the user in P3 when routes + the client swap go live.
## Happy Path (owner review script)
## Happy Path (P1 — test/README script, written before execute)
1. `cd backend && uv sync && uv run pytest` — 129 tests green, zero API keys (live tier: `uv run pytest -m live`, skips cleanly)
2. `cd frontend && corepack pnpm install && corepack pnpm test` — 97 tests green (90 component + 5 live-binding + 2 real-stack E2E)
3. `uv run uvicorn researchx.api.app:app --port 8000` + `NEXT_PUBLIC_RESEARCHX_API_MODE=live corepack pnpm dev` → http://localhost:3000 → Ask a question → Research this → pick Deep Report → Start research → approve the plan (3 clicks) → live SSE trace → report with anchored citations → ledger → export Markdown (citation links) / JSON (backend-schema round-trip)
4. `uv run python -m researchx.engine.demo` — full pipeline on fakes, artifacts in a temp runs dir
5. Owner decision: approve → milestone merge to main + v0.2.5 published as the MVP release; or direct changes → binding fix cycle
1. `cd backend && uv sync` — installs Python 3.12 + deps, editable researchx, dev tools
2. `uv run pytest` — full suite green with zero API keys (fake adapters drive everything, REQ-31)
3. `uv run python -c "from researchx.methodologies import registry; m = registry.get('deep-report'); print(m.dials.depth)"` → `3` — registry resolves by slug, dial bundle typed
4. `uv run python -c "from researchx.config.dials import Dials; print(Dials().model_dump())"` → all 12 dials with safe defaults
5. `uv run ruff check src tests && uv run mypy src` → clean
6. Frontend gates still green (untouched): `corepack pnpm typecheck && corepack pnpm test` from frontend/
## UX Acceptance Criteria
## UX Acceptance Criteria (P1)
- ≤3 clicks from cold start to initiated research, validated on the real stack (REQ-17)
- Every trust claim attack-proven: hallucinated citations fail the export gate; fabricated quotes on real passage IDs flag unanchored; SSRF redirects die; pause/resume/cancel survive the loop
- Honest scope: live mode = core research loop (G-002); library/settings/suggestions/refine stay mock-backed until v0.3+ — ratified at this gate (G-009)
- No secret value in any artifact, trace, or log; zero-key test discipline throughout
- One-command backend setup (uv sync) with zero manual steps beyond uv itself
- Every adapter contract is typed; fakes implement the full interface — no stub methods, no TODOs
- No API key is ever required to run the test suite; no secret value ever reaches a log line, error message, or fixture
- Degradation is honest: probe results and structured-output warnings are typed data, not console noise
## What this phase did
## Tasks (waves — dependency-ordered, persona-assigned)
1. **Review** (multi-persona, probe-verified): 4 P0 + 15 P1 + 15 P2. The 4 P0s broke the milestone's flagship guarantees and were all fixed with attack-proofs: quote anchoring extracted the stored passage's own quote (always 1.0) instead of the writer's text; the export-gate denominator counted unprobed visited pages as dead; malformed planner JSON left zombie runs; fetch adapters followed redirects without re-guarding. Six cheap P1s also fixed.
2. **Audit**: PASS-WITH-NOTES — reconstruction, secrets sweep (clean), branch/tag hygiene exact; both critical notes (stale traceability rows, missing P5 plan) fixed in-phase.
3. **Ship (this commit sequence)**: PR #14 (phase/05 → milestone), squash-merge, tag v0.2.5 — the MVP release candidate, release with the full milestone summary. Per D-018 and the merge_to_main escalation hook, the milestone merge to main does NOT proceed autonomously — the pipeline halts for the recorded owner decision, with G-009 (live-mode scope) presented at the same gate.
### Wave 1 — Schema core (data-engineer)
- **T-1.1** `backend/pyproject.toml` + src-layout skeleton: packages config, deps, dev group, ruff/mypy/pytest config (hatchling packages key, pythonpath src, asyncio_mode auto) [REQ-33 seed — tooling; full monorepo story completes in P3/P4]
- **T-1.2** Dial schema: `researchx/config/dials.py` — 12 typed dials (source universe, search routing, query strategy, breadth, depth, quality gates, citation rigor, verification passes, synthesis style, output structure, memory scope, checkpoints), each with safe default; pydantic v2 [REQ-01]
- **T-1.3** Methodology model + registry: `researchx/methodologies/` — methodology = named dial bundle + planner/writer prompt templates + output shape; registry resolves by slug; ships Rapid Answer (depth 1, breadth 1, memo, per-claim citations) + Deep Report (depth 3, breadth 2, executive report, full verification); behavior-difference contract documented + asserted (different query sets/depth/output shape) [REQ-02, REQ-03, REQ-05]
- **T-1.4** Run-config contract: resolved dial values snapshot model (persisted per run — storage wiring lands with the engine in P2; schema + round-trip test here) [REQ-04]
## Scope Guard
### Wave 2 — Adapter contracts + fakes (backend-engineer)
- **T-1.5** Adapter contract types: `researchx/adapters/` — model (OpenAI-compatible, planner/writer roles), search (Tavily/DDG), storage (local FS), memory (stub), secrets (env-file); all I/O behind interfaces; httpx confined to adapters [REQ-06, REQ-10, REQ-11]
- **T-1.6** Fake adapters: FakeModelAdapter (canned planner/writer), FakeSearchAdapter (fixture corpus) — full interface implementations; zero API keys [REQ-31]
- **T-1.7** Storage adapter: local FS write of run artifacts (Markdown/JSON under user-chosen dir); run ID generation (8-char slug) [REQ-10, D-010]
- **T-1.8** Secrets adapter: env-file resolution (.ciagent/.env.secrets + .env scopes per config.json); never logs values [REQ-11]
Milestone merge to main, v0.2.5 publication, and any deploy wait on the owner approval gate (D-018/G-009). The pipeline does not proceed autonomously past this commit.
### Wave 3 — Real adapters + probe contracts (backend-engineer)
- **T-1.9** Model adapter: OpenAI-compatible chat completions against OLLAMA_BASE_URL (default https://ollama.com/v1, Bearer OLLAMA_API_KEY) with keyless http://localhost:11434/v1 fallback on auth/connection failure; planner/writer roles [REQ-06, REQ-08]
- **T-1.10** Capability-probe contract + implementation: GET /models + structured-output smoke test; conservative caps: context min(claim, 8192), embedding batch 8; typed probe result with overrides; proven with fakes here — LIVE probing against real endpoints executes in P2 [REQ-07 contract, G-001]
- **T-1.11** Structured-output warning contract: smoke-test failure → typed warning surfaced for UI; degrade to prompt-embedded JSON contract — never cryptic failure [REQ-12 contract, G-001]
- **T-1.12** Search adapter: Tavily (POST api.tavily.com/search, key from secrets) default; DDG keyless fallback (html.duckduckgo.com/html conservative scrape, max 8, one backoff, typed degradation warning — degraded-mode contract per G-008); registry selects by key presence [REQ-09, D-012]
### Wave 4 — Integration proofs (ci-lead-developer)
- **T-1.13** Cross-cutting test: registry → dial resolution → fake-model plan fixture → dial snapshot round-trip; methodology behavior-difference assertion (Rapid Answer vs Deep Report query sets/depth/output shape) [REQ-05]
- **T-1.14** README + VERSIONING updates: backend quickstart (uv sync/uv run uvicorn), adapter config table (env vars, no values), monorepo command map [REQ-33 partial]
## Out of scope for P1 (later phases)
- Engine state machine, evidence store, trust loop (P2); API routes, SSE, OpenAPI client swap (P3); adversarial/E2E suite (P4)
## Milestone-level constraints promoted from grill G-001..G-010 (binding, absorbed by phase plans)
- **Live-mode surface (G-002)**: v0.2 live mode = core research loop only (runs, methodologies, lifecycle, SSE, config status). Library persistence, Settings persistence, Suggestions, Refine, graph/inbox, profile stay mock-bound until v0.3+ — owner ratifies at the P5 gate (escalation G-009)
- **ApiClient extension (G-003)**: P3 adds pauseRun/resumeRun as the SINGLE planned interface extension; client.ts endpoint-mapped surfaces beyond the core loop stay mock-backed
- **Liveness gate (G-004)**: REQ-23 ≥99% is a runtime per-run export gate + an opt-in keyed live-URL test tier — never a zero-key CI assertion on the fixture corpus
- **OpenAPI snapshot discipline (G-005)**: P3 must commit an openapi.json snapshot; regen only from the snapshot; schema-drift gate; stable operation-ids
- **P2 split lever (G-006)**: if P2 slips beyond one extension cycle it may ship as two patches (P2a engine/SSE, P2b trust/probe); P3 swap endpoint plan drafted at P2 start
- **Cost counters (G-007)**: per-run advisory request/token/search counters surface via GET /api/config (P3)
- **Contingency lever (G-010)**: if P2/P4 slip beyond one extension cycle, Deep Report defers to v0.3 and the MVP ships Rapid Answer end-to-end — owner sign-off required to pull this lever
## Verification hooks (consumed by VERIFY)
- Gates: `uv run ruff check src tests`, `uv run mypy src`, `uv run pytest` (backend); `corepack pnpm typecheck/lint/test/build` (frontend, must stay green)
- REQ coverage assert: REQ-01..06, 08..11, 31 each mapped to ≥1 test or contract proof
- Security: no secret values in code/fixtures/logs; SSRF not yet applicable (no page fetching until P2 evidence store)
+5 -5
View File
@@ -129,9 +129,9 @@
| REQ-62 (approval gate) | v0.1 | P8 | complete (owner approval recorded 2026-09-27) |
| REQ-63, REQ-64, REQ-65, REQ-66 (app shell, library, settings, avatar) | v0.1 | P5 | complete |
| REQ-67, REQ-68 (ask suggestions, refine dialogue) | v0.1 | P7 | complete |
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | complete (v0.2.1; REQ-07/12 contracts proven, live probing in P2) |
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | complete (v0.2.2) |
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | complete (v0.2.3) |
| REQ-17, REQ-32, REQ-34 (adversarial, E2E, round-trip) | v0.2 | P4 | complete (v0.2.4) |
| All REQ-01..34 (milestone gate) | v0.2 | P5 | complete (v0.2.5 — **owner approval recorded 2026-09-27**, G-009 ratified; milestone merged to main) |
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | blocked |
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | blocked |
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | blocked |
| REQ-17, REQ-32, REQ-34 (adversarial, E2E, round-trip) | v0.2 | P4 | blocked |
| All REQ-01..34 (milestone gate) | v0.2 | P5 | blocked |
| REQ-35..51 | v2 backlog (post-MVP milestones) | — | pending |
+12 -29
View File
@@ -75,7 +75,7 @@ prototype release is **v0.1.8** (final phase patch). Milestone branch
merged to main and deleted — tags v0.1.0–v0.1.8 preserve the full
history. Next milestone: v0.2 MVP (unblocked).
## Milestone v0.2 — MVP (feature) — COMPLETE (approved 2026-09-27)
## Milestone v0.2 — MVP (feature) — UNBLOCKED (prototype approved 2026-09-27)
Phases renumbered from the original v1.0 plan (1-based within the milestone). Tags run on the v0.2.x line; MAJOR stays 0.
@@ -84,50 +84,33 @@ Phases renumbered from the original v1.0 plan (1-based within the milestone). Ta
- **Requirements covered**: none (planning artifacts)
- **Success criteria**: .ciagent/ files refreshed for v0.2; PLAN.md carries User-Facing Surface + Happy Path + UX Acceptance Criteria; PERSONAS.md v0.2 roster active; grill verdict recorded with binding decisions
- **Depends on**: v0.1 approval (recorded)
- **Status**: complete (PR #9 squash-merged to milestone/v0.2-mvp bbf80ce, tag v0.2.0, release created HTTP 201, phase branch deleted; grill Proceed 0.72 with G-001..G-010 promoted; MVP/UX PASS)
- **Status**: in progress
### Phase 1 — Methodology Framework + Adapter Layer
- **Description**: Dial schema (12 dials, typed, safe defaults), methodology registry with Rapid Answer + Deep Report, adapter contracts (model/search/storage/memory/secrets) with local implementations, capability probing with conservative caps (8192 context, embedding batch 8, Ollama cloud-first + local fallback), fake model/search adapters. Full task waves in PLAN.md.
- **Requirements covered**: REQ-01..06, REQ-08..11, REQ-31 (REQ-07, REQ-12 ship in P2 per ROADMAP phase mapping — dial-level + warning contracts are proven here, live probing against real endpoints in P2)
- **Success criteria**: uv one-command backend; gates green zero-API-key; registry resolves slugs; dial snapshot round-trips; fakes drive full interface; degradation typed + honest
- **Depends on**: 0
- **Status**: complete (PR #10 squash-merged to milestone/v0.2-mvp dfb738d, tag v0.2.1, release HTTP 201, phase branch deleted; verify PASS 0 P0 / 2 P1 fixed in-phase / 9 P2 flagged; REQ-07/12 contracts proven with fakes per G-001, live probing in P2)
- **Status**: blocked → next (unblocks on P0 ship)
### Phase 2 — Research Engine + Trust Loop (complete — shipped v0.2.2)
### Phase 2 — Research Engine + Trust Loop
- **Description**: Asyncio state machine with durable JSON state and pause/cancel/resume; plan approval gate; typed event bus → SSE; evidence store with stable passage IDs; trust loop (visited-only constraint, URL liveness pass, quote anchoring, citation ledger, verification checklist, confidence labels).
- **Requirements covered**: REQ-07, REQ-12, REQ-13..24
- **Status**: complete (PR #11 squash-merged 558f21a, tag v0.2.2, release HTTP 201, phase branch deleted; verify found 2 P0 + 9 P1, all fixed in-phase with regression pins; SSE transport itself lands in P3 — the bus + trace artifacts shipped here)
- **Status**: blocked → next (sequential phases; unblock as each prior phase ships)
### Phase 3 — API Layer + Integration (complete — shipped v0.2.3)
### Phase 3 — API Layer + Integration
- **Description**: FastAPI routes (runs, methodologies, lifecycle, SSE events, config status); OpenAPI-generated typed client; **swap MockApiClient → generated client** (the ApiClient interface absorbs any prototype-era API drift); ETA/cost heuristics for methodology cards.
- **Requirements covered**: REQ-25, REQ-26
- **Status**: complete (PR #12 squash-merged 75479a9, tag v0.2.3, release HTTP 201, phase branch deleted; verify found 1 P0 mode-flag + 4 P1, all fixed in-phase; REQ-33 one-command story completed here per plan)
- **Status**: blocked → next (sequential phases; unblock as each prior phase ships)
### Phase 4 — Hardening + Adversarial Suite + E2E (complete — shipped v0.2.4)
- **Description**: Adversarial test suite (hallucinated-URL writer, quote-anchor mismatch, pause/resume mid-depth-loop), E2E happy-path (real stack via live binding + dev bridge), monorepo tooling polish (one-command install/dev, lint/typecheck/test green).
### Phase 4 — Hardening + Adversarial Suite + E2E
- **Description**: Adversarial test suite (hallucinated-URL writer, quote-anchor mismatch, pause/resume mid-depth-loop), E2E happy-path (Playwright), monorepo tooling polish (one-command install/dev, lint/typecheck/test green).
- **Requirements covered**: REQ-17, REQ-32, REQ-33, REQ-34
- **Status**: complete (PR #13 squash-merged dcf2475, tag v0.2.4, release HTTP 201, phase branch deleted; verify found 5 P1 honesty gaps in the suite itself — export gate anchored, weak band pinned, fabricated reports fail export; E2E validated on the real stack, defaults only)
- **Status**: blocked → next (sequential phases; unblock as each prior phase ships)
### Phase 5 — Final Review + Ship (FINAL)
- **Description**: Review + audit + milestone ship; final patch tag IS the MVP release. MAJOR stays 0 — the MVP ships as a 0.x release. v1.0.0 comes only after an owner-approved release decision.
- **Status**: complete (PR #14 squash-merged 023667e, tag v0.2.5, release HTTP 201, phase branch deleted; review 4 P0 + 15 P1 — P0s attack-proven fixed; audit PASS-WITH-NOTES — criticals fixed; **owner approval RECORDED 2026-09-27** — decision commit 4721c2a, G-009 scope ratified; milestone merged to main, v0.2.5 = the MVP release)
## Milestone v0.2 — COMPLETE (approved 2026-09-27)
All requirements complete: REQ-01..26, REQ-31..34 (the MVP set). The
approved MVP release is **v0.2.5** (final phase patch). Delivered: the
FastAPI research engine (asyncio state machine, durable
pause/cancel/resume), methodology framework (12 dials, Rapid Answer +
Deep Report), five-adapter layer (Ollama cloud-first fallback,
Tavily/DDG, storage, memory, secrets), the attack-proven trust loop
(real quote anchoring, cited-scoped ≥99% liveness export gate, SSRF-
guarded fetching, citation ledger, verification checklist), SSE live
trace, OpenAPI-generated typed client behind the unchanged ApiClient
boundary, a 14-attack adversarial suite, and the 3-click E2E on the
real stack. G-009 ratified: live mode = core research loop only;
library/settings/suggestions/refine/graph stay mock-backed until
v0.3+. Milestone branch merged to main and deleted — tags
v0.2.0–v0.2.5 preserve the full history. Next: v0.3 planning.
- **Status**: blocked → next (sequential phases; unblock as each prior phase ships)
## Milestone Releases
- **v0.1.4 = extended-prototype candidate** (superseded by the P5/P6 extension cycle)
@@ -135,7 +118,7 @@ v0.2.0–v0.2.5 preserve the full history. Next: v0.3 planning.
- **v0.1.6 = extended-prototype candidate** (superseded by the P7/P8 extension cycle)
- **v0.1.7 = ask suggestions + refine dialogue** (P7 patch)
- **v0.1.8 = milestone release** (P8 final phase patch — the approved extended prototype release, pending owner decision)
- **v0.2.5 = milestone release** (P5 final phase patch — the approved MVP; owner approval recorded 2026-09-27, G-009 ratified)
- **v0.2.x = MVP release** (MAJOR still 0; ship only if all v0.2 phases complete)
- If MVP scope extends: additional 0.x minor milestones (v0.3, …), each a full milestone cycle
- **v1.0.0**: owner-approved release only — not created by the pipeline autonomously
+10 -22
View File
@@ -2,23 +2,18 @@
"ci_version": "0.7.0",
"projects": [],
"active_project": "researchx",
"milestone": "v0.3",
"milestone_name": "next-milestone-planning",
"milestone": "v0.2",
"milestone_name": "mvp",
"milestone_type": "feature",
"versioning": {
"major_locked": true,
"major_lock_note": "MAJOR remains 0 until an owner-approved v1 release; all pre-v1 ships are MINOR/PATCH only",
"tag_line": "v0.3.x",
"next_milestone": "v0.3 scope definition pending owner direction (post-MVP backlog: live-bound library/settings/suggestions/refine, backend hosting, remaining 6 methodologies)"
"major_lock_note": "MAJOR remains 0 until an owner-approved v1 release; the MVP may span multiple 0.x minor milestones; all pre-v1 ships are MINOR/PATCH only",
"tag_line": "v0.2.x",
"next_milestone": "v0.3+ (post-MVP backlog milestones)"
},
"autonomy": {
"level": "full",
"escalation_hooks": [
"deploy",
"delete_data",
"merge_to_main",
"prototype_approval"
],
"escalation_hooks": ["deploy", "delete_data", "merge_to_main", "prototype_approval"],
"decision_confidence_threshold": 0.6,
"max_revision_iterations": 3,
"clarify_budget": 10
@@ -42,16 +37,9 @@
"secrets": {
"env_file": ".ciagent/.env.secrets",
"scopes": {
"release": [
"GITEA_TOKEN"
],
"model": [
"OLLAMA_API_KEY",
"OLLAMA_BASE_URL"
],
"search": [
"TAVILY_API_KEY"
]
"release": ["GITEA_TOKEN"],
"model": ["OLLAMA_API_KEY", "OLLAMA_BASE_URL"],
"search": ["TAVILY_API_KEY"]
}
}
}
}
+5 -28
View File
@@ -1,8 +1,8 @@
# Deploy Record — UAT stagings and releases
# Deploy Record — UAT stagings of the v0.1 prototype
Owner-directed deployments, recorded per commit discipline. Release
deploys carry the owner approval (v0.1.8); UAT variants stage review
builds without minting release tags.
Owner-directed UAT deployments, recorded per commit discipline. The
approval gate (REQ-62) stays closed; UAT variants stage review builds
without minting release tags.
| Date | Version | Commit | Directive | Result |
|---|---|---|---|---|
@@ -11,8 +11,6 @@ builds without minting release tags.
| 2026-09-27 | v0.1.7-uat | 432d2c0 (P7 ship + boundary docs) | owner cadence: deploy after P7 and P8 | healthy — http://10.0.0.114:3000 |
| 2026-09-27 | v0.1.8-uat | 93a7b57 (P8 ship — release candidate) | owner cadence: deploy after P7 and P8 | healthy — http://10.0.0.114:3000 |
| 2026-09-27 | **v0.1.8 (release)** | 93a7b57 (tagged) | **owner approval recorded — milestone release published** | healthy — http://10.0.0.114:3000 |
| 2026-09-27 | v0.2.5-uat | c28a97e (v0.2 milestone HEAD + v0.2.5 version strings) | owner: "deploy and validate against https://research.coreci.dev" | healthy — https://research.coreci.dev (HAProxy/SSL) |
| 2026-09-27 | **v0.2.5 (release)** | b11b202 (main merge; tag v0.2.5) | **owner approval recorded (/ci-run APPROVED) — milestone release published; G-009 ratified** | serving — same container (v0.2.5-uat build = release content); no redeploy needed |
## v0.1.6-uat (extended prototype for owner review)
@@ -26,25 +24,4 @@ builds without minting release tags.
- Rollback: `ssh root@10.0.0.114 'docker stop researchx; docker rename
researchx-prev researchx; docker start researchx'`
- The approval gate remains closed: milestone merge to main still halts
for the recorded owner decision (REQ-62)
## v0.2.5-uat (MVP release candidate, owner-directed)
- Deployed via `bash .ciagent/release/deploy.sh v0.2.5-uat` — the v0.2
milestone HEAD (c28a97e: tag v0.2.5 + v0.2.5 About/package.json
version strings), per the owner's direct request to deploy and
validate against the public URL
- Gates re-run at deploy time from the milestone HEAD: typecheck,
lint, test (97/97), build — green before sync
- Image `researchx:v0.2.5-uat` + `latest` on the host; container
swapped, `researchx-prev` (v0.1.8) kept for rollback
- Health: 2 checks; validated through the HAProxy SSL front:
https://research.coreci.dev — /ask /library /settings /choose all
200, root 307→/ask, TLS verified; served settings chunk carries
"v0.2.5 · MVP release candidate"; the RESEARCHX_API_MODE switch is
present in the served bundle (mock default — correct for a
frontend-only container; live mode additionally needs the backend
API on the host, which is v0.3+ deployment work)
- The merge gate (D-018) remains closed: milestone → main still halts
for the recorded owner decision, with G-009 (live-mode scope)
presented at the same gate
for the recorded owner decision (REQ-62)
-87
View File
@@ -1,87 +0,0 @@
# UAT Script — v0.2.5 (MVP release candidate)
Deployment: https://research.coreci.dev (HAProxy → LXC container
`researchx:v0.2.5-uat`, commit c28a97e, mock-backed frontend).
Scope note (G-009): this container serves the v0.2.5 frontend with
scripted mock data. The v0.2 engine (FastAPI, adapters, trust loop)
is validated by the automated suites (129 backend + 97 frontend
tests, 14 adversarial attacks, real-stack E2E) and runs via the
README live-mode locally; hosting the backend is v0.3+ work. This
UAT validates the release-candidate build of the product surface.
## 1. Shell & navigation (REQ-63, REQ-66)
1. Open https://research.coreci.dev → lands on **/ask** inside the app shell
2. Sidebar: **New research / Library / Settings** nav present on every screen
3. Top bar: press **⌘K** → navigation palette (screens + stored reports)
4. Click the **avatar** (top right) → user menu: local profile, Settings, About
5. Pass: shell persistent everywhere, honest preview-marked future slots
## 2. Version check (this deploy)
1. Sidebar → **Settings** → scroll to *About & version*
2. Pass: reads **"v0.2.5 · MVP release candidate"** (confirms the deployed build)
## 3. The ≤3-click happy path (REQ-61 / REQ-17 — core criterion)
1. **/ask**: type a question → click **Research this** (click 1)
2. **/choose**: pick a card (Deep Report is the recommended default) → **Start research** (click 2)
3. **Plan & Run**: review the plan → **Approve plan** (click 3)
4. Pass: exactly 3 clicks, defaults only, research running with a live trace
## 4. Plan & Run (REQ-57)
1. Plan approval UI shows sub-questions, source universe, outline
2. Watch the live trace stream (scripted replay)
3. Press **pause** → trace halts; **resume** → continues; **cancel** → terminal state
4. Pass: all three controls behave; no lost events
## 5. Reading Room + exports (REQ-58, REQ-59, REQ-34)
1. Open the finished report from the run screen
2. Hover/focus any **[n]** citation marker → the exact quoted passage appears
3. Open the **citation ledger** panel: URL, quote, health, authority tier
4. **Export Markdown** and **Export JSON** → real files download; citations survive as links
5. Pass: quotes match exactly; files open; links intact
## 6. Ask intelligence (REQ-67, REQ-68)
1. **New research** → below the form: open-question suggestions appear
2. Click one → the question box fills; **never auto-starts**
3. Type a draft → **Refine question** → answer the 3 clarifying questions → **Use this** → refined question + context fill the form
4. Try **Keep original** on a second draft → untouched; keyboard (Enter) works through both flows
5. Pass: both helpers are opt-in, keyboard-accessible, drafts preserved on dismiss
## 7. Library (REQ-64)
1. Sidebar → **Library**: folder tree, auto-categorized (topic + methodology), sortable (recency/title)
2. Open a stored report → its own Reading Room view
3. **Connections** panel: shared-source ⇄ and topical → edges
4. v2 previews: project graph + task inbox panels render
5. Pass: folders, sorting, connections, previews all render from the fixture data
## 8. Settings (REQ-65)
1. API keys: masked (never raw); storage directory field
2. Methodology defaults: resolved 12-dial display
3. Appearance preview; About & version (step 2)
4. Pass: everything honest and functional on mock data
## Optional: hands-on the live engine (not on this URL)
```bash
# locally, two terminals:
cd backend && uv sync && uv run uvicorn researchx.api.app:app --port 8000
cd frontend && NEXT_PUBLIC_RESEARCHX_API_MODE=live corepack pnpm dev
# then repeat steps 3-5 against the real engine (fake adapters, zero keys)
```
## Decision rubric (the gate this UAT feeds)
- **Approve**: recorded via /ci-run → milestone merges to main,
v0.2.5 publishes as the MVP release, v0.3 planning unblocks
(with G-009 scope ratified: library/settings/suggestions/refine/
graph stay mock-backed until v0.3+)
- **Request changes**: feedback becomes binding decisions for a
fix cycle (v0.1 precedent: two feedback cycles before approval)
+1 -1
View File
@@ -9,7 +9,7 @@ as established by the CIAgent branch strategy and `config.json`.
| Milestone | Tags | Deployed by |
|---|---|---|
| v0.1 prototype | v0.1.0 … v0.1.8 | `bash .ciagent/release/deploy.sh v0.1.8` |
| v0.2 MVP | v0.2.0 … v0.2.5 | `bash .ciagent/release/deploy.sh v0.2.5-uat` (staged) — see DEPLOY-RECORD.md |
| v0.2 MVP | v0.2.0 … | `bash .ciagent/release/deploy.sh v0.2.x` |
## Rules
-157
View File
@@ -1,157 +0,0 @@
# .gitea/workflows/ci.yml - ResearchX phase-4 CI gate (researchx#24).
#
# This file IS the gate. The pre-code clause (coreci/company#105, c14057):
# a code phase opens only when the product repo's gating workflow file has
# dispatched at least one run on `main` and every job in that file is green
# in that run. Until this file exists, coreci/researchx has no workflow and
# no run at all - that is the case the clause was amended for (researchx#24,
# c14093). A half-green pipeline does not open the phase either.
#
# Two jobs:
# backend - uv + Python 3.12: ruff, mypy, pytest
# frontend - Node 22 + pnpm 12.6.0: typecheck, lint, test, build
#
# Runner label: `ubuntu-latest` - the org CI fleet's published label whose
# image is docker.gitea.com/runner-images:ubuntu-latest (label map read at
# source: coreci/nextcraft deploy/ci/docker-compose.yaml + deploy/ci/README.md,
# R-3). The previous string `employee-runner` was a fleet NAME, not a label:
# the employee fleet's labels are `employee-py` and `employee-coder` (read
# from a live job log of this seat's own home), and the CI fleet's are node /
# ubuntu-latest / python / release - no runner carried the old label, so a
# merged file asking for it could still buy no run.
#
# Toolchain: measured, not assumed. From the runner's own logs (job 56393,
# coreci/nova-platform, 2026-10-26T23:17Z, and job 57005, coreci/inboxos-v1,
# 2026-10-27T03:01Z): the image carries python 3.12.3, pip, git, node (the
# actions run in it), pypi REACHABLE and github REACHABLE; actions/checkout@v4,
# actions/setup-python@v5 and actions/setup-go@v5 all run inside it. It does
# NOT carry uv or pnpm, so this file installs them: uv from pypi (pip), pnpm
# 12.6.0 through corepack - the repo's own documented way (README command map).
# The gates are the repo's documented commands: backend `uv run ruff check src
# tests`, `uv run mypy src`, `uv run pytest`; frontend `pnpm typecheck`,
# `pnpm lint`, `pnpm test`, `pnpm build`. The frontend lane mirrors the
# release image's own recipe (frontend/Dockerfile: node:22-alpine, corepack,
# pnpm install --frozen-lockfile, pnpm build), so the gate tests what the
# product ships.
#
# Owner of a red leg (the clause asks for the owner of the fix next to the
# owner of the product line): toolchain / image / dispatch problems ->
# coreci-sre (employee-runner-fleet + gitea-instance); product-line problems
# -> coreci-researchx-po. Approval for this file rides the merge request
# (merge = approval, constitution v1.3).
name: ci
on:
push:
pull_request:
branches: ["main"]
workflow_dispatch:
jobs:
backend:
name: "backend (uv, Python 3.12: ruff/mypy/pytest)"
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
# uv is not in the CI image; pypi is reachable from it (measured). The
# install is a toolchain leg, not a product gate: the gates still run
# from the repo's own committed lockfile (backend/uv.lock).
- name: Install uv (from pypi)
run: |
set -eu
python -m pip install --upgrade uv
uv --version
- name: Install dependencies (uv, from the committed lockfile)
working-directory: backend
run: uv sync --frozen
- name: Lint (ruff)
working-directory: backend
run: uv run ruff check src tests
- name: Typecheck (mypy)
working-directory: backend
run: uv run mypy src
- name: Test (pytest)
working-directory: backend
run: uv run pytest
frontend:
name: "frontend (Next.js on pnpm: typecheck/lint/test/build)"
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4
# Node 22 to mirror the release image (frontend/Dockerfile: node:22).
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Setup Python 3.12 (for the real-stack e2e bridge)
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv (from pypi)
run: |
set -eu
python -m pip install --upgrade uv
# pnpm 12.6.0 is pinned by frontend/package.json `packageManager`;
# corepack reads it from there. If the image carries no corepack, npm
# installs the same pinned version.
- name: pnpm 12.6.0 (corepack, pinned by frontend/package.json)
working-directory: frontend
run: |
set -eu
node --version
corepack enable pnpm 2>/dev/null || true
if ! pnpm --version >/dev/null 2>&1; then
npm i -g pnpm@12.6.0
fi
test "$(pnpm --version)" = "12.6.0"
- name: Install dependencies (from the committed lockfile)
working-directory: frontend
run: pnpm install --frozen-lockfile
# frontend/src/lib/api/e2e-real-stack.test.ts drives the real FastAPI
# stack: it spawns `uv run python -m researchx.api.dev_bridge` from
# ../backend (loopback :8000, fakes only, zero API keys). Without a
# synced backend venv that test cannot come up, so the lane provisions
# it. Provisioning only - the backend gates stay in the backend job.
- name: Backend venv for the real-stack e2e bridge
working-directory: backend
run: uv sync --frozen
- name: Typecheck
working-directory: frontend
run: pnpm typecheck
- name: Lint
working-directory: frontend
run: pnpm lint
- name: Test
working-directory: frontend
run: pnpm test
- name: Build
working-directory: frontend
env:
NEXT_TELEMETRY_DISABLED: "1"
run: pnpm build
+5 -91
View File
@@ -1,107 +1,21 @@
# ResearchX
**TL;DR** — ResearchX turns one question into a cited research report: pick a method, approve the plan, and the engine visits real web sources, with every claim anchored to a quoted passage from a page it actually read. The engine milestone (v0.2.5) is shipped and owner-approved; hosting goes live next (v0.3, in progress).
- **What** — FastAPI backend (planning, search, extraction, verification, export gates) plus a Next.js frontend; the full test suite runs with zero API keys.
- **Why** — Readers can check every citation back to its source passage; runs are resumable from durable state.
- **Action** — no action — FYI. Developers: see the Quickstart below.
- **Details** — `frontend/README.md` (web app); `.ciagent/` (pipeline state and roadmap).
A deep-research studio where you bring your own model, storage, and memory; choose a research methodology before a single query fires; and every claim in the final report is anchored to a visited, verified, quotable source.
**Status: MVP shipped (v0.2.5, owner-approved 2026-09-27)** — the full research engine (FastAPI + asyncio state machine + adapter layer + attack-proven trust loop) behind the v0.1 frontend. Live mode covers the core research loop (G-002/G-009): the engine runs locally per the quickstart; library/settings/suggestions/refine stay mock-backed until v0.3+.
**Status: prototype (milestone v0.1)** — UI/UX with mock data. No backend, no API keys, no network calls.
## Quickstart
```bash
# frontend (prototype, mock-backed — works today)
cd frontend
corepack pnpm install
corepack pnpm dev # http://localhost:3000
# backend (MVP — uv manages Python 3.12 and the venv)
cd backend
uv sync # installs deps + editable researchx package
uv run uvicorn researchx.api.app:app --reload --port 8000
uv run pytest # full suite, zero API keys
pnpm install
pnpm dev
```
Open http://localhost:3000 — you land on **Ask**, inside the app shell (left sidebar, top bar with ⌘K search and your avatar). Type a question, click **Research this**, pick a methodology on **Choose** (Deep Report is the recommended default), and press **Start research**.
Three clicks from cold start to initiated research.
## Monorepo command map
| Layer | Install | Dev | Gates |
|---|---|---|---|
| backend (uv, Python 3.12) | `uv sync` | `uv run uvicorn researchx.api.app:app --reload --port 8000` | `uv run ruff check src tests` · `uv run mypy src` · `uv run pytest` |
| frontend (pnpm 12.6) | `corepack pnpm install` | `corepack pnpm dev` | `corepack pnpm typecheck` · `corepack pnpm lint` · `corepack pnpm test` · `corepack pnpm build` |
## Engine architecture (v0.2 P2)
The research engine is a hand-rolled asyncio state machine:
```
PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING ─┐
(halt until ↑ depth loop (dials.depth) │
approved) └────────────────────────────┘
→ SYNTHESIZING → VERIFYING → DONE (+ CANCELLED / FAILED terminals)
```
- Durable state: `runs/<id>/state.json` rewritten atomically after every transition; resume re-enters at the recorded cursor with the persisted plan
- Event bus: typed events (`run_started`, `query_fired`, `source_visited`, `passage_extracted`, `state_changed`, `run_done`, `run_error`) — trace per run
- Visited-only constraint: the writer's context is the evidence store and nothing else
- Trust loop: SSRF-guarded liveness (live/stale/dead), quote anchoring (normalize + fuzzy ≥ 0.85, weak matches flagged), citation ledger JSON, exportable verification checklist, single-source vs corroborated confidence labels
Run artifacts per run: `state.json`, `report.md`, `ledger.json`, `checklist.md`.
Try it on fakes (no keys): `uv run python -m researchx.engine.demo`
## Adversarial guarantees (v0.2 P4)
Every trust-loop claim is attacked by the adversarial suite (`backend/tests/test_adversarial.py`) — these are tests that try to fool the verifier, and their passing proves the defenses:
| Attack | Defense (proven) |
|---|---|
| Writer hallucinates a citation to a passage that was never stored | Unknown passage IDs count as **unanchored claims** in the ledger; the export gate fails the report |
| Fabricated quote (matches nothing) | Anchoring verdict: no match, flagged — never silently accepted |
| Near-miss quote (0.80–0.85 similarity) | Flagged `weak_match` in the ledger — surfaced, not accepted |
| Liveness probe redirected to a private/metadata IP | Per-hop redirect re-guarding classifies it **dead** (SSRF refusal) |
| Redirect chain ending at a gone page (410) | Classified **dead** |
| Oversized page (5MB) response | 2MB read cap — classifies without unbounded memory |
| Pause during the depth loop | Durable state persists; resume completes the run over the HTTP API |
| Cancel mid-loop | Terminal CANCELLED state persisted, never swallowed |
REQ-23 (≥99% citation liveness) is a per-run **export gate** enforced by the verification checklist, plus an opt-in keyed live tier (`cd backend && uv run pytest -m live`) — the zero-key CI suite never asserts it vacuously.
The E2E happy path (≤3 clicks: ask, choose, approve) is validated against the real FastAPI + engine stack, defaults only — not the mock (`frontend/src/lib/api/e2e-real-stack.test.ts`).
## Adapter configuration (v0.2)
All keys resolve from env files only (`.env`, `.env.secrets`, `.env.*` — never the shell environment, never git):
| Variable | Scope | Used by |
|---|---|---|
| `OLLAMA_API_KEY` | model (cloud) | Ollama cloud endpoint `https://ollama.com/v1`; absent/keyless falls back to `http://localhost:11434/v1` automatically |
| `OLLAMA_BASE_URL` | model | Overrides the default cloud base URL |
| `TAVILY_API_KEY` | search | Tavily default search; absent falls back to the keyless DuckDuckGo adapter (degraded mode — surfaced in the run trace) |
The test suite needs **no** keys: fake model/search adapters (REQ-31) drive every engine test.
## Live mode (v0.2 P3)
The frontend runs mock-backed by default (works standalone, zero keys). To run the core research loop against the real backend:
```bash
# terminal 1 — backend (fake adapters by default; real Ollama/Tavily adapters
# are used by the engine in v0.2.5+ wiring — the served API currently ships
# fake-adapter runs for demos and tests; see Adapter configuration below)
cd backend && uv run uvicorn researchx.api.app:app --reload --port 8000
# terminal 2 — frontend in live mode
cd frontend && NEXT_PUBLIC_RESEARCHX_API_MODE=live corepack pnpm dev
```
Live mode covers the core loop (Ask → Choose → approve plan → live trace → report + ledger); the library, settings persistence, suggestions, refine, and graph surfaces stay mock-backed until v0.3. The API surface: `POST /api/runs`, `GET /api/methodologies`, run lifecycle (`approve/pause/cancel/resume`), `GET /api/runs/{id}/events` (SSE), `GET /api/config`, report + ledger artifacts.
## What's in the prototype
| Screen | What it does |
-5
View File
@@ -1,5 +0,0 @@
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
-1
View File
@@ -1 +0,0 @@
3.12
-48
View File
@@ -1,48 +0,0 @@
[project]
name = "researchx"
version = "0.2.0"
description = "ResearchX — methodology-first deep research studio"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115,<0.116",
"pydantic>=2.8,<3",
"uvicorn>=0.30,<1",
"sse-starlette>=2.1,<3",
"httpx>=0.27,<1",
]
[dependency-groups]
dev = [
"ruff>=0.6",
"mypy>=1.11",
"pytest>=8",
"pytest-asyncio>=0.23",
"httpx>=0.28.1",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/researchx"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
extend-select = ["I", "UP"]
[tool.mypy]
strict = true
mypy_path = "src"
python_version = "3.12"
[tool.pytest.ini_options]
pythonpath = ["src"]
markers = [
"live: opt-in keyed tests against real adapters (skipped without keys)",
]
asyncio_mode = "auto"
testpaths = ["tests"]
View File
@@ -1,52 +0,0 @@
import json
import httpx
from researchx.adapters.model import ModelAdapter
from researchx.adapters.probe import (
DEFAULT_EMBEDDING_BATCH,
CapabilityProbe,
cap_context_window,
structured_output_warning,
)
from researchx.adapters.types import ModelMessage
def supports_probe(adapter: ModelAdapter) -> bool:
return getattr(adapter, "probing_supported", False)
async def probe_model_adapter(adapter: ModelAdapter) -> CapabilityProbe:
models = await adapter.list_models()
smoke_ok = await _structured_output_smoke_test(adapter)
writer = adapter.roles().writer
warning = structured_output_warning(smoke_ok, writer)
endpoint = getattr(adapter, "active_base_url", "unknown")
fallback_reason = getattr(adapter, "last_fallback_reason", None)
return CapabilityProbe(
endpoint=str(endpoint),
models=models,
context_window=cap_context_window(None),
embedding_batch=DEFAULT_EMBEDDING_BATCH,
structured_outputs_supported=smoke_ok,
structured_output_warning=warning,
fallback_reason=fallback_reason,
)
async def _structured_output_smoke_test(adapter: ModelAdapter) -> bool:
probe_messages = [
ModelMessage(
role="user",
content='Return a JSON object with exactly one key "ok" set to true. No other text.',
)
]
try:
result = await adapter.chat(probe_messages, model=adapter.roles().writer, json_mode=True)
except (httpx.HTTPError, httpx.HTTPStatusError, ValueError, KeyError):
return False
try:
parsed = json.loads(result.content.strip())
except json.JSONDecodeError:
return False
return isinstance(parsed, dict) and parsed.get("ok") is True
@@ -1,136 +0,0 @@
import re
from datetime import UTC, datetime
from html import unescape
from urllib.parse import parse_qs, unquote, urlparse
import httpx
from researchx.adapters.secrets import SecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter, extract_text, extract_title
from researchx.adapters.types import AdapterError, PageContent, SearchResult
from researchx.adapters.url_guard import url_allowed
_DDG_URL = "https://html.duckduckgo.com/html"
_SEARCH_TIMEOUT = 10.0
_FETCH_TIMEOUT = 10.0
_MAX_FETCH_REDIRECTS = 5
_BACKOFF_SECONDS = 1.0
_USER_AGENT = "researchx/0.2 (local research studio)"
_RESULT_RE = re.compile(
r'<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
re.DOTALL,
)
_SNIPPET_RE = re.compile(r'<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
_TAG_RE = re.compile(r"<[^>]+>")
class DuckDuckGoSearchAdapter:
def __init__(self) -> None:
self._last_degradation: str | None = None
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]:
results = await self._attempt_search(query, max_results)
if results is None:
self._last_degradation = "duckduckgo rate-limited or unreachable; returning partial results"
return []
self._last_degradation = None
return results
async def _attempt_search(self, query: str, max_results: int) -> list[SearchResult] | None:
import asyncio
params = {"q": query}
headers = {"User-Agent": _USER_AGENT}
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=_SEARCH_TIMEOUT) as client:
response = await client.post(_DDG_URL, params=params, headers=headers)
if response.status_code in (403, 429):
if attempt == 0:
await asyncio.sleep(_BACKOFF_SECONDS)
continue
return None
response.raise_for_status()
return self._parse_results(response.text, max_results)
except httpx.HTTPError:
if attempt == 0:
await asyncio.sleep(_BACKOFF_SECONDS)
continue
return None
return None
def _parse_results(self, html: str, max_results: int) -> list[SearchResult]:
anchors = _RESULT_RE.findall(html)
snippets = [self._strip(s) for s in _SNIPPET_RE.findall(html)]
results: list[SearchResult] = []
for index, (href, title_html) in enumerate(anchors):
url = self._normalize_href(href)
if not url:
continue
snippet = snippets[index] if index < len(snippets) else ""
results.append(
SearchResult(
title=self._strip(title_html),
url=url,
snippet=snippet,
)
)
if len(results) >= max_results:
break
return results
def _normalize_href(self, href: str) -> str:
decoded = unescape(href)
parsed = urlparse(decoded)
if parsed.path.startswith("//duckduckgo.com/l/") or parsed.netloc == "duckduckgo.com":
target = parse_qs(parsed.query).get("uddg", [""])[0]
return unquote(target)
if decoded.startswith("http"):
return decoded
return ""
def _strip(self, html: str) -> str:
text = _TAG_RE.sub("", html)
return re.sub(r"\s+", " ", text).strip()
async def fetch(self, url: str) -> PageContent:
from urllib.parse import urljoin
current = url
async with httpx.AsyncClient(
timeout=_FETCH_TIMEOUT,
follow_redirects=False,
headers={"User-Agent": _USER_AGENT},
) as client:
for _ in range(_MAX_FETCH_REDIRECTS + 1):
if not url_allowed(current):
raise AdapterError(
"fetch refused: url not allowed (private or non-http scheme)"
)
response = await client.get(current)
if 300 <= response.status_code < 400:
location = response.headers.get("location")
if not location:
raise AdapterError("fetch failed: redirect without location")
current = urljoin(current, location)
continue
response.raise_for_status()
return PageContent(
url=url,
title=extract_title(response.text),
text=extract_text(response.text),
fetched_at=datetime.now(UTC),
)
raise AdapterError("fetch failed: too many redirects")
@property
def last_degradation(self) -> str | None:
return self._last_degradation
def resolve_search_adapter(
secrets: SecretsProvider,
) -> tuple[TavilySearchAdapter | DuckDuckGoSearchAdapter, str]:
if secrets.resolve("TAVILY_API_KEY"):
return TavilySearchAdapter(secrets), "tavily"
return DuckDuckGoSearchAdapter(), "duckduckgo-keyless"
-165
View File
@@ -1,165 +0,0 @@
from datetime import UTC, datetime
from researchx.adapters.model import ModelRoles
from researchx.adapters.types import (
AdapterError,
ChatResult,
ModelMessage,
PageContent,
SearchResult,
)
def _now() -> datetime:
return datetime.now(UTC)
class FakeModelAdapter:
def __init__(self, canned: list[ChatResult]) -> None:
self._canned = list(canned)
self._json_mode_calls: list[bool] = []
self.roles_value = ModelRoles(planner="fake-planner", writer="fake-writer")
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
del messages, temperature
self._json_mode_calls.append(json_mode)
if not self._canned:
raise AdapterError(f"no canned response for model {model}")
return self._canned.pop(0)
async def list_models(self) -> list[str]:
return ["fake-model"]
def roles(self) -> ModelRoles:
return self.roles_value
@property
def last_json_mode(self) -> bool | None:
return self._json_mode_calls[-1] if self._json_mode_calls else None
class FakeSearchAdapter:
def __init__(self, corpus: list[PageContent]) -> None:
self._corpus = list(corpus)
self._by_url = {page.url: page for page in corpus}
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]:
terms = [t.lower() for t in query.split() if t]
results: list[SearchResult] = []
for page in self._corpus:
haystack = f"{page.title}\n{page.text}".lower()
if all(t in haystack for t in terms):
snippet = page.text[:200]
results.append(SearchResult(title=page.title, url=page.url, snippet=snippet))
if len(results) >= max_results:
break
return results
async def fetch(self, url: str) -> PageContent:
page = self._by_url.get(url)
if page is None:
raise AdapterError(f"unknown url: {url}")
return page.model_copy(update={"fetched_at": _now()})
SHARED_SENTENCE = (
"The verification pass re-anchored every quoted claim to its stored passage "
"before the report was allowed to ship."
)
def corpus() -> list[PageContent]:
stamp = _now()
pages = [
PageContent(
url="https://example.org/verification",
title="Verification passes in research pipelines",
text=(
"Deep research pipelines fail when citations are decorative. "
f"{SHARED_SENTENCE} The pipeline treats every quote as a claim "
"about evidence, not a claim about the world.\n\n"
"Verification therefore runs twice: once at generation time and "
"once at export time — liveness is rechecked, anchors are "
"re-matched, and the ledger records the outcome."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/anchoring",
title="Quote anchoring and auditability",
text=(
f"{SHARED_SENTENCE} Anchoring is the mechanism that makes this "
"auditability real: each claim carries a fuzzy-matched pointer "
"back to the exact stored passage — d’abord normalisé, puis "
"comparé. 近似匹配的阈值设定为 0.85.\n\n"
"A claim without an anchor is not a finding; it is a hypothesis "
"with better typography."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/ollama",
title="OpenAI-compatible endpoints on Ollama",
text=(
"Ollama exposes an OpenAI-compatible surface at /v1: chat "
"completions accept the standard message array, and model "
"listing works via GET /v1/models. Cloud deployments require "
"a bearer key; localhost deployments are keyless by default.\n\n"
"Capability probing stays conservative: cap context at the "
"minimum of the model claim and 8192 tokens, and batch "
"embeddings at 8."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/search-routing",
title="Search routing across keyless and keyed providers",
text=(
"A keyed provider (Tavily) is the default route; a keyless "
"fallback (DuckDuckGo HTML) degrades gracefully under rate "
"limiting. The fallback is a degraded mode, not a second "
"source — its warnings surface in the run trace.\n\n"
"Routing decisions belong to the methodology dials, never to "
"the writer."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/state-machines",
title="Durable state machines for research engines",
text=(
"A research engine survives restarts by persisting state after "
"every transition: an atomic tmp-plus-replace write leaves no "
"torn files, and a cursor records the depth-loop position.\n\n"
"Pause clears an asyncio event; cancel persists a terminal "
"state; resume re-enters the loop at the recorded cursor. "
"CancelledError is re-raised after the write — never swallowed."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/methodology",
title="Methodology-first research selection",
text=(
"Choosing a methodology before a single query fires converts "
"ad-hoc prompt crafting into a structured, reproducible "
"decision. Dials — breadth, depth, citation rigor, "
"verification passes — resolve into a snapshot that persists "
"with every run.\n\n"
"The methodology registry ships Rapid Answer for memo-shaped "
"answers and Deep Report for executive reports with full "
"verification."
),
fetched_at=stamp,
),
]
return pages
CORPUS = corpus()
@@ -1,73 +0,0 @@
import asyncio
import os
import re
import secrets as secrets_module
from pathlib import Path
from researchx.adapters.types import AdapterError
_RUN_ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
_RUN_ID_RE = re.compile(r"^[a-z0-9]{8}$")
_ARTIFACT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def generate_run_id() -> str:
return "".join(secrets_module.choice(_RUN_ID_ALPHABET) for _ in range(8))
def _validate_run_id(run_id: str) -> None:
if not _RUN_ID_RE.match(run_id):
raise AdapterError("invalid run id")
def _validate_artifact_name(name: str) -> None:
if not _ARTIFACT_NAME_RE.match(name) or Path(name).name != name:
raise AdapterError("invalid artifact name")
class LocalStorageAdapter:
def __init__(self, base_dir: Path) -> None:
self._base_dir = base_dir
def run_dir(self, run_id: str) -> Path:
_validate_run_id(run_id)
return self._base_dir / "runs" / run_id
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path:
_validate_artifact_name(name)
target = self.run_dir(run_id) / name
target.parent.mkdir(parents=True, exist_ok=True)
tmp_path = target.with_name(f"{target.name}.{secrets_module.token_hex(4)}.tmp")
def _write_atomic() -> None:
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(target)
try:
await asyncio.to_thread(_write_atomic)
finally:
tmp_path.unlink(missing_ok=True)
return target
async def read_run_artifact(self, run_id: str, name: str) -> str:
_validate_artifact_name(name)
target = self.run_dir(run_id) / name
try:
return target.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise AdapterError(f"artifact not found: runs/{run_id}/{name}") from exc
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path:
_validate_artifact_name(name)
target = self.run_dir(run_id) / name
target.parent.mkdir(parents=True, exist_ok=True)
def _append() -> None:
with open(target, "a", encoding="utf-8") as handle:
handle.write(content)
await asyncio.to_thread(_append)
return target
-8
View File
@@ -1,8 +0,0 @@
from typing import Protocol, runtime_checkable
@runtime_checkable
class MemoryAdapter(Protocol):
async def remember(self, scope: str, key: str, value: str) -> None: ...
async def recall(self, scope: str, key: str) -> str | None: ...
-26
View File
@@ -1,26 +0,0 @@
from typing import Protocol, runtime_checkable
from pydantic import BaseModel
from researchx.adapters.types import ChatResult, ModelMessage
class ModelRoles(BaseModel):
planner: str
writer: str
@runtime_checkable
class ModelAdapter(Protocol):
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult: ...
async def list_models(self) -> list[str]: ...
def roles(self) -> ModelRoles: ...
-118
View File
@@ -1,118 +0,0 @@
from typing import Any
import httpx
from researchx.adapters.model import ModelRoles
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage, Usage
_DEFAULT_CLOUD_BASE = "https://ollama.com/v1"
_DEFAULT_LOCAL_BASE = "http://localhost:11434/v1"
_REQUEST_TIMEOUT = 60.0
class OllamaModelAdapter:
probing_supported = True
def __init__(
self,
secrets: EnvFileSecretsProvider,
planner_model: str,
writer_model: str,
cloud_base_url: str | None = None,
local_base_url: str | None = None,
) -> None:
self._secrets = secrets
self._roles = ModelRoles(planner=planner_model, writer=writer_model)
self._cloud_base = (cloud_base_url or secrets.resolve("OLLAMA_BASE_URL") or _DEFAULT_CLOUD_BASE).rstrip("/")
self._local_base = (local_base_url or _DEFAULT_LOCAL_BASE).rstrip("/")
self._active_base = self._cloud_base
self._api_key = secrets.resolve("OLLAMA_API_KEY")
self._last_fallback_reason: str | None = None
def _headers(self, base_url: str) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if base_url == self._cloud_base and self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
return headers
async def _post_chat(
self,
base_url: str,
payload: dict[str, object],
) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
response = await client.post(
f"{base_url}/chat/completions",
json=payload,
headers=self._headers(base_url),
)
try:
response.raise_for_status()
except httpx.HTTPError as exc:
raise AdapterError(f"chat request failed ({type(exc).__name__})") from exc
data: dict[str, Any] = response.json()
return data
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
payload: dict[str, object] = {
"model": model,
"messages": [m.model_dump() for m in messages],
"temperature": temperature,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
try:
data = await self._post_chat(self._active_base, payload)
except AdapterError as first_error:
if self._active_base == self._cloud_base:
self._last_fallback_reason = f"cloud endpoint failed: {type(first_error.__cause__).__name__ if first_error.__cause__ else 'Error'}"
self._active_base = self._local_base
data = await self._post_chat(self._local_base, payload)
else:
raise
choice = data["choices"][0]["message"]
raw_usage = data.get("usage") or {}
usage = Usage(
prompt_tokens=raw_usage.get("prompt_tokens"),
completion_tokens=raw_usage.get("completion_tokens"),
)
return ChatResult(content=choice["content"], model=model, usage=usage)
async def list_models(self) -> list[str]:
try:
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
response = await client.get(
f"{self._active_base}/models",
headers=self._headers(self._active_base),
)
try:
response.raise_for_status()
except httpx.HTTPError as exc:
raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc
data: dict[str, Any] = response.json()
return [str(entry["id"]) for entry in data.get("data", [])]
except (AdapterError, httpx.HTTPError):
if self._active_base == self._cloud_base:
self._last_fallback_reason = "cloud models listing failed; using local"
self._active_base = self._local_base
return await self.list_models()
raise
def roles(self) -> ModelRoles:
return self._roles
@property
def active_base_url(self) -> str:
return self._active_base
@property
def last_fallback_reason(self) -> str | None:
return self._last_fallback_reason
-34
View File
@@ -1,34 +0,0 @@
from pydantic import BaseModel
DEFAULT_CONTEXT_CAP = 8192
DEFAULT_EMBEDDING_BATCH = 8
class CapabilityProbe(BaseModel):
endpoint: str
models: list[str]
context_window: int
embedding_batch: int
structured_outputs_supported: bool
structured_output_warning: str | None = None
fallback_reason: str | None = None
@property
def degraded(self) -> bool:
return self.structured_output_warning is not None or self.fallback_reason is not None
def cap_context_window(model_claim: int | None) -> int:
if model_claim is None or model_claim <= 0:
return DEFAULT_CONTEXT_CAP
return min(model_claim, DEFAULT_CONTEXT_CAP)
def structured_output_warning(supported: bool, model_name: str) -> str | None:
if supported:
return None
return (
f"Model '{model_name}' did not confirm structured-output (function-calling) support. "
"Methodologies needing structured outputs will degrade to a prompt-embedded JSON "
"contract — expect looser parsing, not a hard failure."
)
-10
View File
@@ -1,10 +0,0 @@
from typing import Protocol, runtime_checkable
from researchx.adapters.types import PageContent, SearchResult
@runtime_checkable
class SearchAdapter(Protocol):
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]: ...
async def fetch(self, url: str) -> PageContent: ...
-32
View File
@@ -1,32 +0,0 @@
from pathlib import Path
from typing import Protocol, runtime_checkable
@runtime_checkable
class SecretsProvider(Protocol):
def resolve(self, name: str) -> str | None: ...
def _parse_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip("'\"")
return values
class EnvFileSecretsProvider:
def __init__(self, paths: list[Path]) -> None:
self._paths = [p for p in paths if p.is_file()]
self._values: dict[str, str] = {}
for path in self._paths:
self._values.update(_parse_env_file(path))
def resolve(self, name: str) -> str | None:
return self._values.get(name)
def __repr__(self) -> str:
return f"EnvFileSecretsProvider(paths={[p.name for p in self._paths]})"
-11
View File
@@ -1,11 +0,0 @@
from pathlib import Path
from typing import Protocol, runtime_checkable
@runtime_checkable
class StorageAdapter(Protocol):
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path: ...
async def read_run_artifact(self, run_id: str, name: str) -> str: ...
def run_dir(self, run_id: str) -> Path: ...
-87
View File
@@ -1,87 +0,0 @@
import re
from datetime import UTC, datetime
import httpx
from researchx.adapters.secrets import SecretsProvider
from researchx.adapters.types import AdapterError, PageContent, SearchResult
from researchx.adapters.url_guard import url_allowed
_TAVILY_URL = "https://api.tavily.com/search"
_SEARCH_TIMEOUT = 10.0
_FETCH_TIMEOUT = 10.0
_MAX_FETCH_REDIRECTS = 5
_USER_AGENT = "researchx/0.2 (local research studio)"
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
_BLOCK_RE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
_TAG_RE = re.compile(r"<[^>]+>")
def extract_text(html: str) -> str:
text = _BLOCK_RE.sub(" ", html)
text = _TAG_RE.sub(" ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def extract_title(html: str) -> str:
match = _TITLE_RE.search(html)
return match.group(1).strip() if match else ""
class TavilySearchAdapter:
def __init__(self, secrets: SecretsProvider) -> None:
self._api_key = secrets.resolve("TAVILY_API_KEY")
if not self._api_key:
raise AdapterError("TAVILY_API_KEY not present in secrets scope")
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]:
payload = {
"api_key": self._api_key,
"query": query,
"search_depth": "basic",
"max_results": max_results,
"include_answer": False,
}
async with httpx.AsyncClient(timeout=_SEARCH_TIMEOUT) as client:
response = await client.post(_TAVILY_URL, json=payload)
response.raise_for_status()
data = response.json()
return [
SearchResult(
title=str(entry.get("title", "")),
url=str(entry.get("url", "")),
snippet=str(entry.get("content", "")),
)
for entry in data.get("results", [])
][:max_results]
async def fetch(self, url: str) -> PageContent:
from urllib.parse import urljoin
current = url
async with httpx.AsyncClient(
timeout=_FETCH_TIMEOUT,
follow_redirects=False,
headers={"User-Agent": _USER_AGENT},
) as client:
for _ in range(_MAX_FETCH_REDIRECTS + 1):
if not url_allowed(current):
raise AdapterError(
"fetch refused: url not allowed (private or non-http scheme)"
)
response = await client.get(current)
if 300 <= response.status_code < 400:
location = response.headers.get("location")
if not location:
raise AdapterError("fetch failed: redirect without location")
current = urljoin(current, location)
continue
response.raise_for_status()
return PageContent(
url=url,
title=extract_title(response.text),
text=extract_text(response.text),
fetched_at=datetime.now(UTC),
)
raise AdapterError("fetch failed: too many redirects")
-36
View File
@@ -1,36 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
class ModelMessage(BaseModel):
role: str
content: str
class Usage(BaseModel):
prompt_tokens: int | None = None
completion_tokens: int | None = None
class ChatResult(BaseModel):
content: str
model: str
usage: Usage | None = None
class SearchResult(BaseModel):
title: str
url: str
snippet: str
class PageContent(BaseModel):
url: str
title: str
text: str
fetched_at: datetime
class AdapterError(Exception):
pass
@@ -1,44 +0,0 @@
import socket
from ipaddress import IPv4Address, IPv6Address
from urllib.parse import urlparse
def _host_is_private(hostname: str) -> bool:
try:
addr_info = socket.getaddrinfo(hostname, None)
except (socket.gaierror, UnicodeError):
return True
for family, _, _, _, sockaddr in addr_info:
try:
if family == socket.AF_INET6:
ip: IPv4Address | IPv6Address = IPv6Address(sockaddr[0])
else:
ip = IPv4Address(sockaddr[0])
except ValueError:
return True
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
):
return True
return False
def url_allowed(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
if not parsed.hostname:
return False
return not _host_is_private(parsed.hostname)
class FetchRefusedError(Exception):
pass
__all__ = ["FetchRefusedError", "url_allowed"]
-358
View File
@@ -1,358 +0,0 @@
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ValidationError
from sse_starlette.sse import EventSourceResponse
from researchx.adapters.capability_probe import probe_model_adapter, supports_probe
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import AdapterError, ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus, RunEvent
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.methodologies import registry
_CANNED_PLAN = (
'{"sub_questions": ["What is the research question?"], '
'"source_universe": ["web"], "outline": ["approach", "findings", "conclusion"]}'
)
_CANNED_WRITER = (
"# Research Memo\n\n"
"The verified core finding [p-1]. A second corroborated observation [p-2].\n"
)
class CreateRunBody(BaseModel):
question: str = Field(min_length=1, max_length=2000)
context: str = Field(default="", max_length=8000)
methodology: str
dial_overrides: dict[str, Any] = Field(default_factory=dict)
class ApproveBody(BaseModel):
plan: dict[str, Any] | None = None
class RunSummary(BaseModel):
id: str
status: str
question: str
methodology: str
plan: dict[str, Any] | None = None
started_at: str
class MethodologyCard(BaseModel):
slug: str
name: str
description: str
eta_minutes: int
cost_estimate: str
why_recommended: str
output_shape: str
dials: dict[str, Any]
class ConfigStatus(BaseModel):
model_adapter: str
search_adapter: str
storage_dir: str
probe: dict[str, Any] | None = None
cost_counters: dict[str, int]
_STATUS_MAP = {
RunStateName.PLANNING: "planning",
RunStateName.AWAITING_APPROVAL: "awaiting_approval",
RunStateName.SEARCHING: "searching",
RunStateName.EXTRACTING: "extracting",
RunStateName.REFLECTING: "reflecting",
RunStateName.SYNTHESIZING: "synthesizing",
RunStateName.VERIFYING: "verifying",
RunStateName.DONE: "done",
RunStateName.CANCELLED: "cancelled",
RunStateName.FAILED: "failed",
}
_ETA_TABLE = {"rapid-answer": (2, "low"), "deep-report": (12, "medium")}
def _cost_estimate(slug: str) -> str:
depth, breadth = 1, 1
methodology = registry.get(slug)
depth = methodology.dials.depth
breadth = methodology.dials.breadth
units = depth * breadth
if units <= 1:
return "~5k tokens (estimate)"
if units <= 4:
return "~25k tokens (estimate)"
return "~60k tokens (estimate)"
def _why_recommended(slug: str) -> str:
return {
"rapid-answer": "One pass, one memo — fastest path to a confident, cited answer.",
"deep-report": "Iterative depth with full verification — when the answer must stand up to scrutiny.",
}.get(slug, "Methodology-first research.")
class RunRegistry:
def __init__(self) -> None:
self._actors: dict[str, tuple[RunActor, asyncio.Task[RunState]]] = {}
self._buses: dict[str, EventBus] = {}
self.cost_counters: dict[str, int] = {"runs_created": 0, "runs_completed": 0}
def add(self, run_id: str, actor: RunActor, task: asyncio.Task[RunState], bus: EventBus) -> None:
self._actors[run_id] = (actor, task)
self._buses[run_id] = bus
self.cost_counters["runs_created"] += 1
def actor(self, run_id: str) -> RunActor:
entry = self._actors.get(run_id)
if entry is None:
raise KeyError(run_id)
return entry[0]
def bus(self, run_id: str) -> EventBus:
bus = self._buses.get(run_id)
if bus is None:
raise KeyError(run_id)
return bus
def has(self, run_id: str) -> bool:
return run_id in self._actors
def note_completed(self) -> None:
self.cost_counters["runs_completed"] += 1
def create_app(
*,
storage_dir: Path | None = None,
model_adapter: Any = None,
search_adapter: Any = None,
) -> FastAPI:
app = FastAPI(title="ResearchX API", version="0.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
storage = LocalStorageAdapter(base_dir=storage_dir or Path.cwd() / ".researchx")
runs = RunRegistry()
model = model_adapter or FakeModelAdapter(
canned=[
ChatResult(content=_CANNED_PLAN, model="fake-planner"),
ChatResult(content=_CANNED_WRITER, model="fake-writer"),
]
)
search = search_adapter or FakeSearchAdapter(CORPUS)
def _summary(run_id: str) -> RunSummary:
actor = runs.actor(run_id)
state = actor.state
return RunSummary(
id=run_id,
status=_STATUS_MAP[state.state],
question=state.question,
methodology=state.methodology_slug,
plan=state.plan.model_dump(mode="json") if state.plan else None,
started_at=state.created_at.isoformat(),
)
@app.get("/api/config")
async def config_status() -> ConfigStatus:
latest_probe = None
for run_id in list(runs._actors)[-1:]:
actor_probe = runs.actor(run_id).state.probe
latest_probe = actor_probe
return ConfigStatus(
model_adapter=type(model).__name__,
search_adapter=type(search).__name__,
storage_dir=str(storage.run_dir("00000000").parent),
probe=latest_probe,
cost_counters=runs.cost_counters,
)
@app.get("/api/methodologies")
async def methodologies() -> list[MethodologyCard]:
cards: list[MethodologyCard] = []
for methodology in registry.list():
eta_minutes, _ = _ETA_TABLE.get(methodology.slug, (5, "low"))
cards.append(
MethodologyCard(
slug=methodology.slug,
name=methodology.name,
description=methodology.description,
eta_minutes=eta_minutes,
cost_estimate=_cost_estimate(methodology.slug),
why_recommended=_why_recommended(methodology.slug),
output_shape=methodology.output_shape,
dials=methodology.dials.model_dump(mode="json"),
)
)
return cards
@app.post("/api/runs", status_code=201)
async def create_run(body: CreateRunBody) -> RunSummary:
if body.methodology not in registry:
raise HTTPException(status_code=404, detail="unknown methodology")
try:
run_id, config = make_run_config(
question=body.question,
context=body.context,
methodology_slug=body.methodology,
overrides=body.dial_overrides,
registry=registry,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=422, detail="invalid dial override") from exc
bus = EventBus()
actor = RunActor(
run_config=config,
model=model,
search=search,
storage=storage,
registry=registry,
event_bus=bus,
on_complete=runs.note_completed,
)
probe_data: dict[str, Any] | None = None
if supports_probe(model):
try:
probe = await probe_model_adapter(model)
probe_data = probe.model_dump(mode="json")
await actor.attach_probe(probe_data)
except AdapterError:
probe_data = None
task = asyncio.create_task(actor.run())
runs.add(run_id, actor, task, bus)
async def _watch() -> None:
await task
asyncio.create_task(_watch())
return _summary(run_id)
@app.get("/api/runs/{run_id}")
async def get_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
return _summary(run_id)
@app.post("/api/runs/{run_id}/approve")
async def approve_run(run_id: str, body: ApproveBody | None = None) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
actor = runs.actor(run_id)
edited_plan: Plan | None = None
if body and body.plan:
try:
edited_plan = Plan.model_validate(body.plan)
except ValidationError:
raise HTTPException(status_code=422, detail="invalid plan")
await actor.approve(edited_plan)
return _summary(run_id)
@app.post("/api/runs/{run_id}/pause")
async def pause_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
runs.actor(run_id).pause()
return _summary(run_id)
@app.post("/api/runs/{run_id}/resume")
async def resume_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
runs.actor(run_id).resume()
return _summary(run_id)
@app.post("/api/runs/{run_id}/cancel")
async def cancel_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
actor = runs.actor(run_id)
actor.cancel()
actor.resume()
return _summary(run_id)
@app.get("/api/runs/{run_id}/events")
async def run_events(run_id: str) -> EventSourceResponse:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
bus = runs.bus(run_id)
actor = runs.actor(run_id)
terminal = {RunStateName.DONE, RunStateName.CANCELLED, RunStateName.FAILED}
async def event_stream() -> AsyncIterator[dict[str, str]]:
queue: asyncio.Queue[RunEvent] = asyncio.Queue()
async def forward(event: RunEvent) -> None:
await queue.put(event)
bus.subscribe(forward)
try:
replay = bus.replay()
watermark = replay[-1].seq if replay else 0
for prior in replay:
yield {"event": prior.type, "data": prior.model_dump_json()}
while True:
if actor.state.state in terminal:
try:
event = await asyncio.wait_for(queue.get(), timeout=0.5)
except TimeoutError:
return
else:
event = await queue.get()
if event.seq <= watermark:
continue
yield {"event": event.type, "data": event.model_dump_json()}
if event.type in ("run_done", "run_error"):
return
finally:
bus.unsubscribe(forward)
return EventSourceResponse(event_stream())
return EventSourceResponse(event_stream())
@app.get("/api/runs/{run_id}/report")
async def run_report(run_id: str) -> dict[str, Any]:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "report.md")
except AdapterError:
raise HTTPException(status_code=404, detail="report not ready")
return {"run_id": run_id, "markdown": content}
@app.get("/api/runs/{run_id}/ledger")
async def run_ledger(run_id: str) -> dict[str, Any]:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "ledger.json")
except AdapterError:
raise HTTPException(status_code=404, detail="ledger not ready")
import json
data: dict[str, Any] = json.loads(content)
return data
return app
app = create_app()
-85
View File
@@ -1,85 +0,0 @@
"""Dev bridge: serve the FastAPI app on a loopback port for E2E tests.
Prints READY on stdout once the server is listening; SIGTERM shuts
down cleanly. Single-user, loopback-only, disposable — for tests and
local dev. Uses an inexhaustible scripted model so many sequential
runs work without restocking canned responses.
"""
import contextlib
import json
import sys
import tempfile
from pathlib import Path
from typing import Any
import uvicorn
from researchx.adapters.fakes import CORPUS, FakeSearchAdapter
from researchx.adapters.types import ChatResult, ModelMessage
from researchx.api.app import create_app
_HOST = "127.0.0.1"
_PORT = 8000
_PLAN = json.dumps(
{
"sub_questions": ["verification passes"],
"source_universe": ["web"],
"outline": ["approach", "findings"],
}
)
_WRITER = (
"# Research Memo\n\n"
"The verified core finding [p-1]. A corroborated observation [p-2].\n"
)
class EndlessModelAdapter:
def __init__(self) -> None:
self.calls = 0
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
del messages, temperature
self.calls += 1
if json_mode:
return ChatResult(content=_PLAN, model=model)
return ChatResult(content=_WRITER, model=model)
async def list_models(self) -> list[str]:
return ["endless-model"]
def roles(self) -> Any:
from researchx.adapters.model import ModelRoles
return ModelRoles(planner="endless-model", writer="endless-model")
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
app = create_app(
storage_dir=Path(tmp),
model_adapter=EndlessModelAdapter(),
search_adapter=FakeSearchAdapter(CORPUS),
)
@app.on_event("startup")
def _announce() -> None:
print(f"READY port={_PORT}", flush=True)
config = uvicorn.Config(app, host=_HOST, port=_PORT, log_level="warning")
server = uvicorn.Server(config)
with contextlib.suppress(KeyboardInterrupt):
server.run()
sys.exit(0)
if __name__ == "__main__":
main()
-111
View File
@@ -1,111 +0,0 @@
from types import MappingProxyType
from typing import Literal
from pydantic import BaseModel, Field
SourceUniverse = Literal["web", "academic", "local", "web+academic"]
SearchRouting = Literal["single", "adaptive", "academic-only"]
QueryStrategy = Literal["fixed", "iterative", "decomposed"]
QualityGates = Literal["none", "standard", "strict"]
CitationRigor = Literal["per-claim", "per-section", "per-report"]
SynthesisStyle = Literal["memo", "executive", "encyclopedic", "adversarial"]
OutputStructure = Literal["brief", "report", "dossier"]
MemoryScope = Literal["none", "project", "cross-project"]
class DialDescriptor(BaseModel, frozen=True):
"""UI display metadata for a single dial."""
name: str
label: str
description: str
class Dials(BaseModel, frozen=True):
"""The 12 methodology dials with safe defaults (REQ-01)."""
source_universe: SourceUniverse = "web"
search_routing: SearchRouting = "single"
query_strategy: QueryStrategy = "decomposed"
breadth: int = Field(default=2, ge=1, le=5)
depth: int = Field(default=1, ge=1, le=3)
quality_gates: QualityGates = "standard"
citation_rigor: CitationRigor = "per-claim"
verification_passes: int = Field(default=1, ge=0, le=3)
synthesis_style: SynthesisStyle = "memo"
output_structure: OutputStructure = "brief"
memory_scope: MemoryScope = "none"
checkpoints: bool = False
DIAL_REGISTRY: MappingProxyType[str, DialDescriptor] = MappingProxyType(
{
"source_universe": DialDescriptor(
name="source_universe",
label="Source universe",
description="Which sources may be searched: web, academic, local, or both.",
),
"search_routing": DialDescriptor(
name="search_routing",
label="Search routing",
description="How queries are routed: single endpoint, adaptive, or academic-only.",
),
"query_strategy": DialDescriptor(
name="query_strategy",
label="Query strategy",
description="How the question becomes queries: fixed, iterative, or decomposed.",
),
"breadth": DialDescriptor(
name="breadth",
label="Breadth",
description="Number of parallel sub-question searches (1-5).",
),
"depth": DialDescriptor(
name="depth",
label="Depth",
description="Number of reflection and refinement loops (1-3).",
),
"quality_gates": DialDescriptor(
name="quality_gates",
label="Quality gates",
description="How strictly sources are filtered before synthesis.",
),
"citation_rigor": DialDescriptor(
name="citation_rigor",
label="Citation rigor",
description="Granularity of citations: per claim, per section, or per report.",
),
"verification_passes": DialDescriptor(
name="verification_passes",
label="Verification passes",
description="Post-synthesis verification passes over claims (0-3).",
),
"synthesis_style": DialDescriptor(
name="synthesis_style",
label="Synthesis style",
description="Voice of the synthesized report.",
),
"output_structure": DialDescriptor(
name="output_structure",
label="Output structure",
description="Shape of the final artifact: brief, report, or dossier.",
),
"memory_scope": DialDescriptor(
name="memory_scope",
label="Memory scope",
description="What prior context a run may draw on.",
),
"checkpoints": DialDescriptor(
name="checkpoints",
label="Checkpoints",
description="Persist intermediate state so a run can pause and resume.",
),
}
)
__all__ = [
"DIAL_REGISTRY",
"DialDescriptor",
"Dials",
]
@@ -1,49 +0,0 @@
import secrets
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from researchx.config.dials import Dials
from researchx.methodologies.registry import MethodologyRegistry
class UnknownOverrideError(ValueError):
"""Raised when an override key is not a dial name."""
class RunConfig(BaseModel, frozen=True):
"""Persisted per-run snapshot of resolved dials (REQ-04)."""
run_id: str = Field(pattern=r"^[a-z0-9]{8}$")
methodology_slug: str
question: str = Field(min_length=1)
context: str = ""
resolved_dials: Dials
created_at: datetime
overrides: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(frozen=True)
def resolve_dials(
methodology_slug: str,
overrides: Mapping[str, Any],
registry: MethodologyRegistry,
) -> Dials:
"""Resolve a methodology's dial bundle with validated overrides applied."""
bundle = registry.get(methodology_slug).dials
for key in overrides:
if key not in Dials.model_fields:
raise UnknownOverrideError(f"unknown dial override: {key}")
merged = bundle.model_dump()
merged.update(overrides)
return Dials.model_validate(merged)
def new_run_id() -> str:
return secrets.token_hex(4)
__all__ = ["RunConfig", "UnknownOverrideError", "new_run_id", "resolve_dials"]
-373
View File
@@ -1,373 +0,0 @@
import asyncio
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
import httpx
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.model import ModelAdapter
from researchx.adapters.search import SearchAdapter
from researchx.adapters.types import AdapterError, ModelMessage
from researchx.adapters.url_guard import url_allowed
from researchx.config.dials import Dials
from researchx.config.run_config import RunConfig, resolve_dials
from researchx.engine.events import EventBus, RunEvent
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.evidence.store import EvidenceStore
from researchx.methodologies import MethodologyRegistry
from researchx.trust.ledger import build_ledger, export_checklist
from researchx.trust.liveness import liveness_report
class EngineError(Exception):
pass
class RunActor:
def __init__(
self,
*,
run_config: RunConfig,
model: ModelAdapter,
search: SearchAdapter,
storage: LocalStorageAdapter,
registry: MethodologyRegistry,
event_bus: EventBus,
on_complete: Callable[[], None] | None = None,
) -> None:
self._config = run_config
self._model = model
self._search = search
self._storage = storage
self._registry = registry
self._bus = event_bus
self._on_complete = on_complete
self._store = EvidenceStore()
self.state = RunState(
run_id=run_config.run_id,
question=run_config.question,
context=run_config.context,
methodology_slug=run_config.methodology_slug,
)
methodology = registry.get(run_config.methodology_slug)
self._dials: Dials = run_config.resolved_dials
self.state.depth_target = self._dials.depth
self._methodology = methodology
self._pause = asyncio.Event()
self._pause.set()
self._cancelled = False
self._approved = asyncio.Event()
async def _publish(self, event_type: str, data: dict[str, str] | None = None) -> None:
event = RunEvent(
type=event_type,
run_id=self._config.run_id,
data=data or {},
)
await self._bus.publish(event)
try:
await self._storage.append_run_artifact(
self._config.run_id,
"trace.jsonl",
event.model_dump_json() + "\n",
)
except AdapterError:
pass
async def _persist(self) -> None:
content = self._snapshot()
await self._storage.write_run_artifact(self._config.run_id, "state.json", content)
def _snapshot(self) -> str:
return self.state.model_dump_json()
async def _checkpoint(self, target: RunStateName) -> None:
if self.state.state != target:
self.state.transition(target)
if target is RunStateName.DONE and self._on_complete is not None:
self._on_complete()
await self._persist()
async def _wait_if_paused(self) -> None:
await self._pause.wait()
def pause(self) -> None:
self._pause.clear()
def resume(self) -> None:
self._pause.set()
def cancel(self) -> None:
self._cancelled = True
async def approve(self, plan: Plan | None = None) -> None:
if plan is not None:
self.state.plan = plan
self._approved.set()
async def run(self) -> RunState:
await self._publish("run_started", {"question": self._config.question})
try:
await self._execute()
except asyncio.CancelledError:
await asyncio.shield(self._persist_cancelled())
raise
except AdapterError as exc:
await self._fail(str(exc))
raise EngineError(str(exc)) from exc
except httpx.HTTPError as exc:
await self._fail(f"network error ({type(exc).__name__})")
raise EngineError(f"network error ({type(exc).__name__})") from exc
except EngineError:
await self._fail("engine error")
raise
except Exception as exc:
await self._fail(f"unexpected engine failure ({type(exc).__name__})")
raise EngineError(f"unexpected engine failure ({type(exc).__name__})") from exc
return self.state
async def attach_probe(self, probe_data: dict[str, Any]) -> None:
self.state.probe = probe_data
await self._persist()
await self._publish("probe_attached", {"endpoint": str(probe_data.get("endpoint", ""))})
async def _persist_cancelled(self) -> None:
self.state.state = RunStateName.CANCELLED
await self._persist()
async def _fail(self, message: str) -> None:
await self._publish("run_error", {"message": message})
self.state.state = RunStateName.FAILED
await asyncio.shield(self._persist())
async def _execute(self) -> None:
current = self.state.state
if current == RunStateName.PLANNING:
await self._planning_phase()
elif current == RunStateName.AWAITING_APPROVAL:
pass
elif current in (
RunStateName.SEARCHING,
RunStateName.EXTRACTING,
RunStateName.REFLECTING,
RunStateName.SYNTHESIZING,
):
await self._research_phase()
await self._verify_phase()
return
elif current == RunStateName.VERIFYING:
await self._verify_phase()
return
else:
raise EngineError(f"cannot resume from state {current}")
await self._approval_phase()
await self._research_phase()
await self._verify_phase()
async def _planning_phase(self) -> None:
await self._checkpoint(RunStateName.PLANNING)
await self._wait_if_paused()
if self._cancelled:
await self._persist_cancelled()
raise asyncio.CancelledError
prompt = self._methodology.planner_prompt.format(
question=self._config.question, context=self._config.context
)
result = await self._model.chat(
[ModelMessage(role="user", content=prompt)],
model=self._model.roles().planner,
json_mode=True,
)
plan = self._parse_plan(result.content)
self.state.plan = plan
await self._publish("state_changed", {"state": "AWAITING_APPROVAL"})
def _parse_plan(self, raw: str) -> Plan:
import json
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise AdapterError("planner returned non-JSON plan") from exc
if not isinstance(data, dict):
raise AdapterError("planner plan is not a JSON object")
sub_questions = data.get("sub_questions", [])
source_universe = data.get("source_universe", [])
outline = data.get("outline", [])
if not (
isinstance(sub_questions, list)
and isinstance(source_universe, list)
and isinstance(outline, list)
):
raise AdapterError("planner plan has malformed lists")
return Plan(
sub_questions=[str(q) for q in sub_questions if isinstance(q, str)],
source_universe=[str(u) for u in source_universe if isinstance(u, str)],
outline=[str(o) for o in outline if isinstance(o, str)],
)
async def _approval_phase(self) -> None:
await self._checkpoint(RunStateName.AWAITING_APPROVAL)
while not self._approved.is_set():
if self._cancelled:
await self._persist_cancelled()
raise asyncio.CancelledError
try:
await asyncio.wait_for(self._approved.wait(), timeout=0.1)
except TimeoutError:
continue
await self._checkpoint(RunStateName.SEARCHING)
async def _research_phase(self) -> None:
while True:
await self._check_cancelled()
await self._searching_step()
await self._extracting_step()
if self.state.depth_cursor + 1 >= self.state.depth_target:
break
await self._reflecting_step()
self.state.depth_cursor += 1
await self._persist()
await self._synthesizing_step()
async def _check_cancelled(self) -> None:
if self._cancelled:
await self._persist_cancelled()
raise asyncio.CancelledError
async def _searching_step(self) -> None:
if not self.state.plan or not self.state.plan.sub_questions:
await self._checkpoint(RunStateName.SEARCHING)
return
await self._checkpoint(RunStateName.SEARCHING)
await self._wait_if_paused()
for sub_question in self.state.plan.sub_questions[: self._dials.breadth]:
await self._publish("query_fired", {"query": sub_question})
self.state.bump("queries")
results = await self._search.search(sub_question, max_results=3)
for result in results:
await self._publish(
"source_found",
{"url": result.url, "title": result.title},
)
async def _extracting_step(self) -> None:
await self._checkpoint(RunStateName.EXTRACTING)
await self._wait_if_paused()
if not self.state.plan:
return
for sub_question in self.state.plan.sub_questions[: self._dials.breadth]:
results = await self._search.search(sub_question, max_results=3)
for result in results:
if not url_allowed(result.url):
await self._publish("source_refused", {"url": result.url})
continue
try:
page = await self._search.fetch(result.url)
except (AdapterError, httpx.HTTPError):
continue
self._store.add_page(page.url, page.title, page.text)
await self._publish(
"source_visited",
{"url": page.url, "title": page.title},
)
self.state.bump("pages")
for passage in self._store.passages_for_url(page.url):
await self._publish(
"passage_extracted",
{"passage_id": passage.passage_id, "url": passage.url},
)
self.state.bump("passages")
async def _reflecting_step(self) -> None:
await self._checkpoint(RunStateName.REFLECTING)
await self._wait_if_paused()
prompt = self._methodology.planner_prompt.format(
question=self._config.question, context=self._config.context
)
result = await self._model.chat(
[ModelMessage(role="user", content=prompt)],
model=self._model.roles().planner,
json_mode=True,
)
if self.state.plan:
refined = self._parse_plan(result.content)
self.state.plan = refined
async def _synthesizing_step(self) -> None:
await self._checkpoint(RunStateName.SYNTHESIZING)
await self._wait_if_paused()
await self._check_cancelled()
context_block = "\n\n".join(
f"[{p.passage_id}] ({p.url}) {p.text}" for p in self._store.writer_context()
)
prompt = self._methodology.render_writer_prompt(
question=self._config.question,
context=self._config.context or "(none)",
plan=self.state.plan.model_dump_json() if self.state.plan else "{}",
evidence=context_block or "(no evidence collected)",
)
result = await self._model.chat(
[ModelMessage(role="user", content=prompt)],
model=self._model.roles().writer,
)
report = result.content
await self._storage.write_run_artifact(self._config.run_id, "report.md", report)
self.state.bump("writer_calls")
async def _verify_phase(self) -> None:
await self._checkpoint(RunStateName.VERIFYING)
report = await self._storage.read_run_artifact(self._config.run_id, "report.md")
liveness = await liveness_report(self._cited_urls(report))
ledger = build_ledger(
run_id=self._config.run_id,
report_text=report,
evidence=self._store,
liveness=liveness,
visited_at=datetime.now(UTC),
)
await self._storage.write_run_artifact(
self._config.run_id, "ledger.json", ledger.model_dump_json(indent=2)
)
checklist = export_checklist(ledger)
await self._storage.write_run_artifact(self._config.run_id, "checklist.md", checklist)
await self._checkpoint(RunStateName.DONE)
await self._publish("run_done", {"run_id": self._config.run_id})
def _cited_urls(self, report_text: str) -> list[str]:
import re
pattern = re.compile(r"\[p-(\d+)\]")
cited: list[str] = []
for match in pattern.finditer(report_text):
passage = self._store.get(f"p-{match.group(1)}")
if passage and passage.url not in cited:
cited.append(passage.url)
return cited
def make_run_config(
*,
question: str,
context: str,
methodology_slug: str,
overrides: dict[str, object],
registry: MethodologyRegistry,
run_id: str | None = None,
) -> tuple[str, RunConfig]:
from researchx.adapters.local_storage import generate_run_id
rid = run_id or generate_run_id()
dials = resolve_dials(methodology_slug, overrides, registry)
config = RunConfig(
run_id=rid,
methodology_slug=methodology_slug,
question=question,
context=context,
resolved_dials=dials,
overrides=dict(overrides),
created_at=datetime.now(UTC),
)
return rid, config
-68
View File
@@ -1,68 +0,0 @@
import asyncio
import tempfile
from pathlib import Path
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.methodologies import registry
async def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
run_id, config = make_run_config(
question="How do verification pipelines keep citations honest?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
)
plan = (
'{"sub_questions": ["verification passes", "quote anchoring"], '
'"source_universe": ["web"], "outline": ["intro", "findings"]}'
)
writer = (
"# Research Report\n\n"
"The verification pass re-anchored every quoted claim [p-1] "
"before the report shipped [p-2].\n"
)
model = FakeModelAdapter(
canned=[
ChatResult(content=plan, model="fake-planner"),
ChatResult(content=writer, model="fake-writer"),
]
)
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=base)
bus = EventBus()
actor = RunActor(
run_config=config, model=model, search=search, storage=storage, registry=registry,
event_bus=bus,
)
task = asyncio.create_task(actor.run())
while actor.state.state.value == "PLANNING":
await asyncio.sleep(0.01)
await actor.approve()
await task
state = (storage.run_dir(run_id) / "state.json").read_text()
report = (storage.run_dir(run_id) / "report.md").read_text()
ledger = (storage.run_dir(run_id) / "ledger.json").read_text()
checklist = (storage.run_dir(run_id) / "checklist.md").read_text()
print(f"run {run_id} → {actor.state.state.value}")
print(f"artifacts under {storage.run_dir(run_id)}:")
for name, content in [
("state.json", state),
("report.md", report),
("ledger.json", ledger),
("checklist.md", checklist),
]:
print(f"--- {name} ({len(content)} chars) ---")
print(f"events: {len(bus.replay())}")
if __name__ == "__main__":
asyncio.run(main())
-49
View File
@@ -1,49 +0,0 @@
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Literal
from pydantic import BaseModel, Field
class RunEvent(BaseModel):
type: str
run_id: str
at: datetime = Field(default_factory=lambda: datetime.now(UTC))
data: dict[str, str] = {}
seq: int = 0
class EventBus:
def __init__(self) -> None:
self._subscribers: list[Callable[[RunEvent], Awaitable[None]]] = []
self._log: list[RunEvent] = []
self._seq = 0
def subscribe(self, handler: Callable[[RunEvent], Awaitable[None]]) -> None:
self._subscribers.append(handler)
def unsubscribe(self, handler: Callable[[RunEvent], Awaitable[None]]) -> None:
if handler in self._subscribers:
self._subscribers.remove(handler)
async def publish(self, event: RunEvent) -> None:
self._seq += 1
event.seq = self._seq
self._log.append(event)
for handler in self._subscribers:
await handler(event)
def replay(self, after_seq: int = 0) -> list[RunEvent]:
return [e for e in self._log if e.seq > after_seq]
class TraceWriter:
def __init__(self, emit: Callable[[str], None]) -> None:
self._emit = emit
async def record(self, event: RunEvent) -> None:
self._emit(event.model_dump_json())
def event_type_of(event: RunEvent) -> Literal["run_started", "other"]:
return "run_started" if event.type == "run_started" else "other"
-36
View File
@@ -1,36 +0,0 @@
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel, Field
from researchx.engine.states import RunStateName, can_transition
class Plan(BaseModel):
sub_questions: list[str] = Field(default_factory=list)
source_universe: list[str] = Field(default_factory=list)
outline: list[str] = Field(default_factory=list)
class RunState(BaseModel):
run_id: str
question: str
context: str = ""
methodology_slug: str
state: RunStateName = RunStateName.PLANNING
plan: Plan | None = None
depth_cursor: int = 0
depth_target: int = 1
counters: dict[str, int] = Field(default_factory=dict)
probe: dict[str, Any] | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
def transition(self, target: RunStateName) -> None:
if not can_transition(self.state, target):
raise ValueError(f"illegal transition: {self.state} -> {target}")
self.state = target
self.updated_at = datetime.now(UTC)
def bump(self, key: str) -> None:
self.counters[key] = self.counters.get(key, 0) + 1
-65
View File
@@ -1,65 +0,0 @@
from enum import StrEnum
class RunStateName(StrEnum):
PLANNING = "PLANNING"
AWAITING_APPROVAL = "AWAITING_APPROVAL"
SEARCHING = "SEARCHING"
EXTRACTING = "EXTRACTING"
REFLECTING = "REFLECTING"
SYNTHESIZING = "SYNTHESIZING"
VERIFYING = "VERIFYING"
DONE = "DONE"
CANCELLED = "CANCELLED"
FAILED = "FAILED"
_LEGAL_TRANSITIONS: dict[RunStateName, set[RunStateName]] = {
RunStateName.PLANNING: {RunStateName.AWAITING_APPROVAL, RunStateName.FAILED},
RunStateName.AWAITING_APPROVAL: {
RunStateName.SEARCHING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.SEARCHING: {
RunStateName.EXTRACTING,
RunStateName.SYNTHESIZING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.EXTRACTING: {
RunStateName.REFLECTING,
RunStateName.SYNTHESIZING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.REFLECTING: {
RunStateName.SEARCHING,
RunStateName.SYNTHESIZING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.SYNTHESIZING: {
RunStateName.VERIFYING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.VERIFYING: {
RunStateName.DONE,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.DONE: set(),
RunStateName.CANCELLED: set(),
RunStateName.FAILED: set(),
}
TERMINAL_STATES = {
RunStateName.DONE,
RunStateName.CANCELLED,
RunStateName.FAILED,
}
def can_transition(current: RunStateName, target: RunStateName) -> bool:
return target in _LEGAL_TRANSITIONS[current]
-70
View File
@@ -1,70 +0,0 @@
from pydantic import BaseModel, Field
class Passage(BaseModel):
passage_id: str
url: str
quote: str
text: str
class VisitedPage(BaseModel):
url: str
title: str
passage_ids: list[str] = Field(default_factory=list)
def extract_passages(text: str, *, min_chars: int = 120) -> list[str]:
paragraphs = [p.strip() for p in text.split("\n") if p.strip()]
return [p for p in paragraphs if len(p) >= min_chars]
class EvidenceStore:
def __init__(self) -> None:
self._passages: dict[str, Passage] = {}
self._pages: dict[str, VisitedPage] = {}
self._seen_quotes: set[str] = set()
self._counter = 0
def add_page(self, url: str, title: str, text: str) -> VisitedPage:
page = self._pages.setdefault(url, VisitedPage(url=url, title=title))
for paragraph in extract_passages(text):
self.add_passage(url, paragraph)
return page
def add_passage(self, url: str, text: str) -> Passage:
self._counter += 1
passage_id = f"p-{self._counter}"
passage = Passage(passage_id=passage_id, url=url, quote=text[:200], text=text)
self._passages[passage_id] = passage
page = self._pages.get(url)
if page is None:
page = VisitedPage(url=url, title="")
self._pages[url] = page
page.passage_ids.append(passage_id)
self._seen_quotes.add(text[:200])
return passage
def has_seen(self, quote: str) -> bool:
return quote[:200] in self._seen_quotes
def get(self, passage_id: str) -> Passage | None:
return self._passages.get(passage_id)
def passages_for_url(self, url: str) -> list[Passage]:
page = self._pages.get(url)
if page is None:
return []
return [self._passages[pid] for pid in page.passage_ids if pid in self._passages]
def writer_context(self) -> list[Passage]:
return [self._passages[pid] for pid in sorted(self._passages, key=self._passage_order)]
def _passage_order(self, passage_id: str) -> int:
return int(passage_id.split("-")[1])
def pages(self) -> list[VisitedPage]:
return list(self._pages.values())
def __len__(self) -> int:
return len(self._passages)
@@ -1,9 +0,0 @@
from researchx.methodologies.builtin import registry
from researchx.methodologies.models import Methodology
from researchx.methodologies.registry import MethodologyRegistry
__all__ = [
"Methodology",
"MethodologyRegistry",
"registry",
]
@@ -1,97 +0,0 @@
from researchx.config.dials import Dials
from researchx.methodologies.models import Methodology
from researchx.methodologies.registry import MethodologyRegistry
_RAPID_PLANNER_PROMPT = (
"You are a rapid research planner. Decompose the user's question into the fewest "
"focused sub-questions that yield a confident answer.\n"
"Question: {question}\n"
"Context: {context}"
)
_RAPID_WRITER_PROMPT = (
"You are a rapid memo writer. Answer the question directly in a short memo.\n"
"Cite a source per claim using the passage markers [p-N]. Every cited quote must "
"come verbatim from the evidence passages listed below and from nowhere else.\n"
"Question: {question}\n"
"Context: {context}\n"
"Plan: {plan}\n"
"Evidence passages:\n{evidence}"
)
_DEEP_PLANNER_PROMPT = (
"You are a deep research planner. Decompose the user's question into two "
"sub-questions, then propose an outline for an executive report with "
"sections, each mapped to the sub-questions it answers.\n"
"Question: {question}\n"
"Context: {context}"
)
_DEEP_WRITER_PROMPT = (
"You are an executive report writer. Synthesize the evidence into a structured "
"executive report following the plan: executive summary, findings, risks, and "
"recommendations.\n"
"Cite a source per claim using the passage markers [p-N]. Every cited quote must "
"come verbatim from the evidence passages listed below and from nowhere else.\n"
"Question: {question}\n"
"Context: {context}\n"
"Plan: {plan}\n"
"Evidence passages:\n{evidence}"
)
_BUILTIN: dict[str, Methodology] = {
"rapid-answer": Methodology(
slug="rapid-answer",
name="Rapid Answer",
description=(
"One search pass, one synthesis pass: a short cited memo for questions "
"that need a confident answer fast."
),
dials=Dials(
source_universe="web",
search_routing="single",
query_strategy="decomposed",
breadth=1,
depth=1,
quality_gates="standard",
citation_rigor="per-claim",
verification_passes=1,
synthesis_style="memo",
output_structure="brief",
memory_scope="none",
checkpoints=False,
),
planner_prompt=_RAPID_PLANNER_PROMPT,
writer_prompt=_RAPID_WRITER_PROMPT,
output_shape="memo",
),
"deep-report": Methodology(
slug="deep-report",
name="Deep Report",
description=(
"Full-depth research: iterative sub-questions, reflective refinement, and "
"a verified executive report with per-claim citations."
),
dials=Dials(
source_universe="web+academic",
search_routing="adaptive",
query_strategy="decomposed",
breadth=2,
depth=3,
quality_gates="strict",
citation_rigor="per-claim",
verification_passes=2,
synthesis_style="executive",
output_structure="report",
memory_scope="none",
checkpoints=True,
),
planner_prompt=_DEEP_PLANNER_PROMPT,
writer_prompt=_DEEP_WRITER_PROMPT,
output_shape="executive-report",
),
}
registry = MethodologyRegistry(_BUILTIN)
__all__ = ["registry"]
@@ -1,32 +0,0 @@
from typing import Literal
from pydantic import BaseModel, Field
from researchx.config.dials import Dials
OutputShape = Literal["memo", "executive-report"]
class Methodology(BaseModel, frozen=True):
"""A named research methodology: a dial bundle plus prompt templates (REQ-02)."""
slug: str = Field(pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$")
name: str
description: str
dials: Dials
planner_prompt: str
writer_prompt: str
output_shape: OutputShape
def render_planner_prompt(self, question: str, context: str) -> str:
return self.planner_prompt.format(question=question, context=context)
def render_writer_prompt(
self, question: str, context: str, plan: str, evidence: str
) -> str:
return self.writer_prompt.format(
question=question, context=context, plan=plan, evidence=evidence
)
__all__ = ["Methodology", "OutputShape"]
@@ -1,26 +0,0 @@
from collections.abc import Mapping
from researchx.methodologies.models import Methodology
class MethodologyRegistry:
"""Frozen by-slug registry that hands out immutable copies (REQ-02/REQ-03)."""
def __init__(self, methodologies: Mapping[str, Methodology]) -> None:
self._methodologies: dict[str, Methodology] = dict(methodologies)
def get(self, slug: str) -> Methodology:
return self._methodologies[slug].model_copy(deep=True)
def list(self) -> list[Methodology]:
ordered = sorted(self._methodologies.values(), key=lambda m: m.name)
return [m.model_copy(deep=True) for m in ordered]
def __contains__(self, slug: str) -> bool:
return slug in self._methodologies
def __len__(self) -> int:
return len(self._methodologies)
__all__ = ["MethodologyRegistry"]
-89
View File
@@ -1,89 +0,0 @@
import re
import unicodedata
from difflib import SequenceMatcher
from pydantic import BaseModel
from researchx.evidence.store import EvidenceStore, Passage
ANCHOR_THRESHOLD = 0.85
WEAK_MATCH_MIN = 0.80
def normalize(text: str) -> str:
nfc = unicodedata.normalize("NFC", text)
collapsed = re.sub(r"\s+", " ", nfc)
return collapsed.casefold().strip()
class AnchorVerdict(BaseModel):
quote: str
passage_id: str | None
ratio: float
matched: bool
weak: bool
def anchor_quote(quote: str, passages: list[Passage]) -> AnchorVerdict:
normalized_quote = normalize(quote)
best_ratio = 0.0
best_id: str | None = None
for passage in passages:
normalized_passage = normalize(passage.text)
if normalized_quote and normalized_quote in normalized_passage:
return AnchorVerdict(
quote=quote, passage_id=passage.passage_id, ratio=1.0, matched=True, weak=False
)
ratio = SequenceMatcher(None, normalized_quote, normalized_passage).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_id = passage.passage_id
matched = best_ratio >= ANCHOR_THRESHOLD
weak = WEAK_MATCH_MIN <= best_ratio < ANCHOR_THRESHOLD
return AnchorVerdict(
quote=quote,
passage_id=best_id if (matched or weak) else None,
ratio=round(best_ratio, 4),
matched=matched,
weak=weak,
)
_MARKER_RE = re.compile(r"\[p-(\d+)\]")
_HEADER_RE = re.compile(r"^#{1,6}\s.*$", re.MULTILINE)
def _claim_fragment(before: str) -> str:
cleaned = _HEADER_RE.sub("", before).rstrip()
fragment_start = max(
cleaned.rfind("."), cleaned.rfind("!"), cleaned.rfind("?"), cleaned.rfind("\n")
)
quote_text = cleaned[fragment_start + 1 :].strip(" \t>\"'*-")
if not quote_text:
quote_text = cleaned[-200:].strip(" \t>\"'*-")
return quote_text
def extract_report_claims(report_text: str) -> list[tuple[str, str]]:
"""Extract (passage_id, quote_text) claims from the report.
The quote text is what the WRITER actually wrote adjacent to the
citation marker — the last sentence fragment before each [p-N]
marker — NOT the stored passage's own text. This is the adversarial
core of REQ-20: a fabricated quote riding a real passage ID must
be visible to the anchorer.
"""
claims: list[tuple[str, str]] = []
for match in _MARKER_RE.finditer(report_text):
passage_id = f"p-{match.group(1)}"
quote_text = _claim_fragment(report_text[: match.start()])
claims.append((passage_id, quote_text))
return claims
def extract_quotes_from_report(report_text: str, evidence: EvidenceStore) -> list[str]:
return [quote for _pid, quote in extract_report_claims(report_text) if evidence.get(_pid)]
def extract_cited_passage_ids(report_text: str) -> list[str]:
return [f"p-{m.group(1)}" for m in _MARKER_RE.finditer(report_text)]
-208
View File
@@ -1,208 +0,0 @@
import re
from datetime import datetime
from pydantic import BaseModel
from researchx.evidence.store import EvidenceStore, Passage
from researchx.trust.anchoring import (
anchor_quote,
extract_cited_passage_ids,
extract_report_claims,
normalize,
)
from researchx.trust.liveness import LivenessStatus
_AUTHORITY_TIERS: dict[str, str] = {
"example.org": "reference",
"gov": "official",
"edu": "academic",
"org": "reference",
"com": "general",
}
def authority_tier(url: str) -> str:
host = re.sub(r"^https?://", "", url).split("/")[0].lower().split(":")[0]
labels = host.split(".")
for suffix, tier in _AUTHORITY_TIERS.items():
if labels[-1] == suffix or (len(labels) > 1 and labels[-2] == suffix):
return tier
return "general"
class CitationEntry(BaseModel):
url: str
visited_at: datetime
passages: list[str]
claims_supported: int
health: str
authority_tier: str
class VerificationChecklist(BaseModel):
run_id: str
citations_checked: int
live: int
stale: int
dead: int
anchored_claims: int
unanchored_claims: int
weak_matches: int
confidence_labels: dict[str, str]
liveness_ratio: float
class CitationLedger(BaseModel):
run_id: str
generated_at: datetime
entries: list[CitationEntry]
checklist: VerificationChecklist
_CLAIM_RE = re.compile(r"\[p-(\d+)\]")
def _claims_per_passage(report_text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for match in _CLAIM_RE.finditer(report_text):
pid = f"p-{match.group(1)}"
counts[pid] = counts.get(pid, 0) + 1
return counts
def build_ledger(
*,
run_id: str,
report_text: str,
evidence: EvidenceStore,
liveness: dict[str, LivenessStatus],
visited_at: datetime,
) -> CitationLedger:
claims = _claims_per_passage(report_text)
all_passages = evidence.writer_context()
report_claims = extract_report_claims(report_text)
cited_ids = extract_cited_passage_ids(report_text)
known_ids = {p.passage_id for p in all_passages}
cited_urls: list[str] = []
entries: list[CitationEntry] = []
anchored = 0
unanchored = 0
weak = 0
confidence: dict[str, str] = {}
for passage_id, writer_quote in report_claims:
passage = evidence.get(passage_id)
if passage is None:
unanchored += 1
continue
verdict = anchor_quote(writer_quote, [passage])
if verdict.matched:
anchored += 1
elif verdict.weak:
weak += 1
else:
unanchored += 1
for cited_id in cited_ids:
if cited_id not in known_ids:
unanchored += 1
passage = evidence.get(cited_id)
if passage is not None and passage.url not in cited_urls:
cited_urls.append(passage.url)
for page in evidence.pages():
if page.url not in cited_urls:
continue
passages = evidence.passages_for_url(page.url)
passage_ids = [p.passage_id for p in passages]
page_claims = sum(claims.get(pid, 0) for pid in passage_ids)
health = str(liveness.get(page.url, LivenessStatus.DEAD))
entries.append(
CitationEntry(
url=page.url,
visited_at=visited_at,
passages=passage_ids,
claims_supported=page_claims,
health=health,
authority_tier=authority_tier(page.url),
)
)
for passage in passages:
confidence[passage.passage_id] = (
"corroborated" if _corroborated(passage, evidence) else "single-source"
)
live = sum(1 for e in entries if e.health == "live")
stale = sum(1 for e in entries if e.health == "stale")
dead = sum(1 for e in entries if e.health == "dead")
total = len(entries)
ratio = round(live / total, 4) if total > 0 else 1.0
checklist = VerificationChecklist(
run_id=run_id,
citations_checked=total,
live=live,
stale=stale,
dead=dead,
anchored_claims=anchored,
unanchored_claims=unanchored,
weak_matches=weak,
confidence_labels=confidence,
liveness_ratio=ratio,
)
return CitationLedger(
run_id=run_id, generated_at=visited_at, entries=entries, checklist=checklist
)
def _corroborated(passage: Passage, evidence: EvidenceStore) -> bool:
normalized = normalize(passage.text)
for other in evidence.writer_context():
if other.passage_id == passage.passage_id:
continue
if normalized in normalize(other.text):
return True
return False
LIVENESS_EXPORT_GATE = 0.99
def liveness_export_gate(ledger: CitationLedger) -> tuple[bool, list[str]]:
failures = [e.url for e in ledger.entries if e.health != "live"]
liveness_ok = ledger.checklist.liveness_ratio >= LIVENESS_EXPORT_GATE
anchoring_ok = ledger.checklist.unanchored_claims == 0
return liveness_ok and anchoring_ok, failures
def export_checklist(ledger: CitationLedger) -> str:
gate_passed, failures = liveness_export_gate(ledger)
lines = [
"# Verification Checklist",
"",
f"Run: {ledger.run_id}",
f"Citations checked: {ledger.checklist.citations_checked}",
f"Live: {ledger.checklist.live} / Stale: {ledger.checklist.stale} / Dead: {ledger.checklist.dead}",
(
f"Liveness ratio: {ledger.checklist.liveness_ratio} (export gate >= {LIVENESS_EXPORT_GATE}: "
f"{'PASS' if gate_passed else 'FAIL'})"
),
(
f"Anchoring gate: {'PASS' if ledger.checklist.unanchored_claims == 0 else 'FAIL'} "
f"({ledger.checklist.unanchored_claims} unanchored)"
),
f"Anchored claims: {ledger.checklist.anchored_claims}",
f"Unanchored claims: {ledger.checklist.unanchored_claims}",
f"Weak matches (flagged, not accepted): {ledger.checklist.weak_matches}",
"",
"## Per-URL ledger",
"",
]
for entry in ledger.entries:
lines.append(
f"- {entry.url} — health: {entry.health}, tier: {entry.authority_tier}, "
f"passages: {len(entry.passages)}, claims: {entry.claims_supported}"
)
if failures:
lines += ["", "## Liveness failures", ""]
lines += [f"- {url}" for url in failures]
return "\n".join(lines) + "\n"
-65
View File
@@ -1,65 +0,0 @@
from enum import StrEnum
import httpx
from researchx.adapters.url_guard import url_allowed
_LIVENESS_TIMEOUT = 10.0
_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
_MAX_REDIRECTS = 5
_USER_AGENT = "researchx/0.2 (liveness check)"
class LivenessStatus(StrEnum):
LIVE = "live"
STALE = "stale"
DEAD = "dead"
async def check_liveness(url: str, client: httpx.AsyncClient | None = None) -> LivenessStatus:
own_client = client is None
active: httpx.AsyncClient = client or httpx.AsyncClient(
timeout=_LIVENESS_TIMEOUT,
follow_redirects=False,
)
current_url = url
try:
for _ in range(_MAX_REDIRECTS + 1):
if not url_allowed(current_url):
return LivenessStatus.DEAD
response = await active.get(
current_url,
headers={"User-Agent": _USER_AGENT},
)
if 300 <= response.status_code < 400:
location = response.headers.get("location")
if not location:
return LivenessStatus.STALE
from urllib.parse import urljoin
current_url = urljoin(current_url, location)
continue
content = b""
async for chunk in response.aiter_bytes():
content += chunk
if len(content) > _MAX_RESPONSE_BYTES:
break
status = response.status_code
if 200 <= status < 300:
return LivenessStatus.LIVE
return LivenessStatus.DEAD
return LivenessStatus.STALE
except (httpx.HTTPError, OSError):
return LivenessStatus.DEAD
finally:
if own_client:
await active.aclose()
async def liveness_report(
urls: list[str],
) -> dict[str, LivenessStatus]:
report: dict[str, LivenessStatus] = {}
for url in dict.fromkeys(urls):
report[url] = await check_liveness(url)
return report
-21
View File
@@ -1,21 +0,0 @@
import secrets as secrets_module
from string import ascii_lowercase
from researchx.adapters.local_storage import generate_run_id
def test_generate_run_id_format() -> None:
run_id = generate_run_id()
assert isinstance(run_id, str)
assert len(run_id) == 8
assert all(c in ascii_lowercase + "23456789" for c in run_id)
assert "o" not in run_id and "l" not in run_id and "0" not in run_id and "1" not in run_id
def test_generate_run_id_entropy() -> None:
ids = {generate_run_id() for _ in range(100)}
assert len(ids) == 100
def test_generate_run_id_uses_secrets_module() -> None:
assert secrets_module.choice is not None
-359
View File
@@ -1,359 +0,0 @@
"""Adversarial suite (REQ-32): attacks on the trust loop, proven caught.
Every test here is an attempt to fool the verifier. If one passes
without being flagged, the trust loop is broken — the suite fails.
"""
import asyncio
import json
from datetime import UTC, datetime
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import AdapterError, ChatResult
from researchx.api.app import create_app
from researchx.evidence.store import EvidenceStore
from researchx.trust.anchoring import anchor_quote
from researchx.trust.ledger import build_ledger, export_checklist, liveness_export_gate
from researchx.trust.liveness import LivenessStatus, check_liveness
def test_attack_hallucinated_url_fails_export_gate() -> None:
store = EvidenceStore()
store.add_passage("https://real.example.org/a", "A real stored passage about the topic " * 5)
report = (
"# Report\n\n"
"Real claim [p-1].\n\n"
"Supporting detail from a hallucinated source [p-2].\n"
)
ledger = build_ledger(
run_id="attack001",
report_text=report,
evidence=store,
liveness={"https://real.example.org/a": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
claim_counts = _claim_counts(ledger)
assert claim_counts.get("p-1", 0) >= 1
assert "p-2" in report
assert not any("p-2" in pids for pids in [e.passages for e in ledger.entries]), (
"hallucinated passage id must not appear in any ledger entry"
)
unanchored = ledger.checklist.unanchored_claims
assert unanchored >= 1, "hallucinated citation must be flagged as unanchored"
def _claim_counts(ledger) -> dict[str, int]:
counts: dict[str, int] = {}
for entry in ledger.entries:
for pid in entry.passages:
counts[pid] = counts.get(pid, 0) + entry.claims_supported
return counts
def test_attack_fabricated_quote_flagged_not_accepted() -> None:
store = EvidenceStore()
for page in CORPUS:
store.add_page(page.url, page.title, page.text)
passages = store.writer_context()
fabricated = "This sentence appears in exactly zero stored passages anywhere."
verdict = anchor_quote(fabricated, passages)
assert verdict.matched is False
assert verdict.weak is False
near_miss = passages[0].text[: len(passages[0].text) // 2] + " And a divergent clause."
near_verdict = anchor_quote(near_miss, passages)
assert near_verdict.matched is False or near_verdict.weak is True
exact = anchor_quote(passages[0].quote, passages)
assert exact.matched is True
def test_attack_quote_from_other_passage_counts_as_corroboration_not_anchor() -> None:
from researchx.adapters.fakes import SHARED_SENTENCE
store = EvidenceStore()
first = store.add_passage("https://a.example.org", SHARED_SENTENCE)
second = store.add_passage("https://b.example.org", SHARED_SENTENCE + " Extra tail text.")
verdict_first = anchor_quote(second.quote, [first])
assert verdict_first.matched is True or verdict_first.ratio > 0.5
async def test_attack_liveness_private_redirect_dies(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(302, headers={"location": "http://169.254.169.254/x"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
status = await check_liveness("https://example.org/redirect", client)
assert status == LivenessStatus.DEAD
async def test_attack_liveness_redirect_chain_to_dead(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
if "hop1" in str(request.url):
return httpx.Response(302, headers={"location": "https://example.org/hop2"})
if "hop2" in str(request.url):
return httpx.Response(302, headers={"location": "https://example.org/hop3"})
return httpx.Response(410)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
status = await check_liveness("https://example.org/hop1", client)
assert status == LivenessStatus.DEAD
async def test_attack_oversized_response_capped(monkeypatch) -> None:
huge = "x" * (5 * 1024 * 1024)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=huge)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
status = await check_liveness("https://example.org/huge", client)
assert status == LivenessStatus.LIVE
async def test_attack_pause_resume_mid_depth_loop_over_http(tmp_path: Path) -> None:
plan_template = json.dumps(
{
"sub_questions": ["verification passes", "quote anchoring"],
"source_universe": ["web"],
"outline": ["intro", "findings"],
}
)
writer = "# Report\n\nClaim [p-1] and claim [p-2].\n"
canned = [
ChatResult(content=plan_template, model="fake-planner"),
ChatResult(content=plan_template, model="fake-planner"),
ChatResult(content=plan_template, model="fake-planner"),
ChatResult(content=writer, model="fake-writer"),
]
app = create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(canned=canned),
search_adapter=FakeSearchAdapter(CORPUS),
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
created = await client.post(
"/api/runs",
json={"question": "q", "methodology": "deep-report"},
)
assert created.status_code == 201
run_id = created.json()["id"]
for _ in range(300):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "awaiting_approval":
break
await asyncio.sleep(0.01)
assert status == "awaiting_approval"
await client.post(f"/api/runs/{run_id}/approve")
paused_seen = False
for _ in range(600):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status in ("reflecting", "searching", "extracting"):
await client.post(f"/api/runs/{run_id}/pause")
await client.post(f"/api/runs/{run_id}/resume")
paused_seen = True
break
if status == "done":
break
await asyncio.sleep(0.005)
for _ in range(600):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.005)
assert status == "done"
assert paused_seen, "mid-loop pause/resume must have been exercised"
report = await client.get(f"/api/runs/{run_id}/report")
assert report.status_code == 200
async def test_attack_cancel_mid_loop_persists(tmp_path: Path) -> None:
plan = json.dumps(
{
"sub_questions": ["verification passes", "quote anchoring"],
"source_universe": ["web"],
"outline": ["intro"],
}
)
app = create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[ChatResult(content=plan, model="fake-planner") for _ in range(10)]
+ [ChatResult(content="# never reached", model="fake-writer")]
),
search_adapter=FakeSearchAdapter(CORPUS),
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "deep-report"}
)
run_id = created.json()["id"]
for _ in range(300):
if (await client.get(f"/api/runs/{run_id}")).json()["status"] == "awaiting_approval":
break
await asyncio.sleep(0.01)
await client.post(f"/api/runs/{run_id}/approve")
for _ in range(600):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status in ("searching", "extracting", "reflecting"):
await client.post(f"/api/runs/{run_id}/cancel")
break
await asyncio.sleep(0.005)
for _ in range(300):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "cancelled":
break
await asyncio.sleep(0.01)
assert status == "cancelled"
def test_attack_weak_band_pinned_real_input() -> None:
store = EvidenceStore()
passage_text = (
"The verification pipeline re-anchored every quoted claim to its stored "
"passage before the report was allowed to ship to the reading room."
)
passage = store.add_passage("https://example.org/weak", passage_text)
words = passage_text.split()
near_miss = " ".join(words[:11]) + " released to the audience."
verdict = anchor_quote(near_miss, [passage])
assert 0.80 <= verdict.ratio < 0.85, f"expected weak band, got {verdict.ratio}"
assert verdict.matched is False
assert verdict.weak is True, f"near-threshold must flag weak, got {verdict.ratio}"
def test_attack_fully_fabricated_report_fails_export() -> None:
store = EvidenceStore()
real = store.add_passage("https://example.org/real", "A genuine stored passage " * 10)
fabricated_report = (
"# Report\n\nClaim [p-1].\n\nClaim [p-7]. Claim [p-42].\n"
)
ledger = build_ledger(
run_id="attack002",
report_text=fabricated_report,
evidence=store,
liveness={"https://example.org/real": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
passed, failures = liveness_export_gate(ledger)
assert passed is False, "fully-fabricated report must fail the export gate"
assert failures == []
assert ledger.checklist.unanchored_claims >= 2
del real
def test_export_round_trip_json_validates_into_backend_model() -> None:
from researchx.trust.ledger import CitationLedger
store = EvidenceStore()
stored_text = "A passage with real content for the round-trip proof."
store.add_passage("https://example.org/a", stored_text)
report = f"# Report\n\n{stored_text} [p-1].\n"
ledger = build_ledger(
run_id="attack003",
report_text=report,
evidence=store,
liveness={"https://example.org/a": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
exported = ledger.model_dump_json()
restored = CitationLedger.model_validate_json(exported)
assert restored == ledger
checklist_md = export_checklist(ledger)
assert "Anchoring gate" in checklist_md
assert "PASS" in checklist_md
def test_attack_fabricated_quote_on_real_passage_id_fails() -> None:
store = EvidenceStore()
stored = store.add_passage(
"https://example.org/real",
"The genuine finding about verification pipelines is that anchoring works. " * 3,
)
report = (
"# Report\n\n"
f"THIS FABRICATED QUOTE WAS NEVER STORED ANYWHERE AT ALL [{stored.passage_id}]\n"
)
ledger = build_ledger(
run_id="attack004",
report_text=report,
evidence=store,
liveness={"https://example.org/real": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
assert ledger.checklist.unanchored_claims >= 1, (
"a fabricated quote riding a real passage ID must be flagged (REQ-20)"
)
passed, _ = liveness_export_gate(ledger)
assert passed is False
def test_attack_real_verbatim_quote_on_real_passage_anchors() -> None:
store = EvidenceStore()
stored_text = (
"The genuine finding about verification pipelines is that anchoring works."
)
stored = store.add_passage("https://example.org/real", stored_text + " " + stored_text)
report = f"# Report\n\n{stored_text} [{stored.passage_id}]\n"
ledger = build_ledger(
run_id="attack005",
report_text=report,
evidence=store,
liveness={"https://example.org/real": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
assert ledger.checklist.anchored_claims == 1
assert ledger.checklist.unanchored_claims == 0
passed, _ = liveness_export_gate(ledger)
assert passed is True
async def test_attack_search_result_redirecting_to_private_ip_refused(monkeypatch) -> None:
import tempfile
from pathlib import Path as PathMod
import httpx as httpx_mod
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter
with tempfile.TemporaryDirectory() as tmpdir:
env_file = PathMod(tmpdir) / ".env.secrets"
env_file.write_text("TAVILY_API_KEY=t-key\n", encoding="utf-8")
secrets = EnvFileSecretsProvider(paths=[env_file])
adapter = TavilySearchAdapter(secrets)
def handler(request: httpx_mod.Request) -> httpx_mod.Response:
if "boobytrap" in str(request.url):
return httpx_mod.Response(
302, headers={"location": "http://169.254.169.254/latest/meta-data"}
)
return httpx_mod.Response(200, text="<html><body>ok</body></html>")
transport = httpx_mod.MockTransport(handler)
original = httpx_mod.AsyncClient
monkeypatch.setattr(
httpx_mod, "AsyncClient", lambda **kw: original(transport=transport, **kw)
)
try:
await adapter.fetch("https://example.org/boobytrap")
except AdapterError as exc:
assert "refused" in str(exc) or "not allowed" in str(exc)
else:
raise AssertionError("fetch of a private-redirecting URL must be refused")
-156
View File
@@ -1,156 +0,0 @@
import asyncio
import json
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import ChatResult
from researchx.api.app import create_app
_PLAN = json.dumps(
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
def _app(tmp_path: Path):
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
]
),
search_adapter=FakeSearchAdapter(CORPUS),
)
async def _client(tmp_path: Path) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(tmp_path)),
base_url="http://testserver",
)
async def test_config_status(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.get("/api/config")
assert response.status_code == 200
body = response.json()
assert body["model_adapter"] == "FakeModelAdapter"
assert body["cost_counters"]["runs_created"] == 0
async def test_methodology_cards(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.get("/api/methodologies")
assert response.status_code == 200
cards = response.json()
slugs = [c["slug"] for c in cards]
assert "rapid-answer" in slugs and "deep-report" in slugs
for card in cards:
assert card["eta_minutes"] > 0
assert "estimate" in card["cost_estimate"]
assert card["why_recommended"]
assert card["dials"]["depth"] >= 1
async def test_create_run_rejects_unknown_methodology(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
response = await client.post(
"/api/runs", json={"question": "q", "methodology": "nonexistent"}
)
assert response.status_code == 404
async def _await_status(client: httpx.AsyncClient, run_id: str, status: str) -> None:
for _ in range(200):
current = (await client.get(f"/api/runs/{run_id}")).json()
if current["status"] == status:
return
await asyncio.sleep(0.01)
raise AssertionError(f"run never reached {status}: {current['status']}")
async def test_run_lifecycle_over_http(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
created = await client.post(
"/api/runs", json={"question": "How do engines run?", "methodology": "rapid-answer"}
)
assert created.status_code == 201
run_id = created.json()["id"]
await _await_status(client, run_id, "awaiting_approval")
approved = await client.post(f"/api/runs/{run_id}/approve")
assert approved.status_code == 200
await _await_status(client, run_id, "done")
report = await client.get(f"/api/runs/{run_id}/report")
assert report.status_code == 200
assert "[p-1]" in report.json()["markdown"]
ledger = await client.get(f"/api/runs/{run_id}/ledger")
assert ledger.status_code == 200
assert ledger.json()["checklist"]["citations_checked"] >= 1
config = (await client.get("/api/config")).json()
assert config["cost_counters"]["runs_created"] == 1
assert config["cost_counters"]["runs_completed"] == 1
async def test_missing_run_404(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
assert (await client.get("/api/runs/zzzz9999")).status_code == 404
assert (await client.post("/api/runs/zzzz9999/approve")).status_code == 404
assert (await client.get("/api/runs/zzzz9999/events")).status_code == 404
async def test_sse_stream_replays_events(tmp_path: Path) -> None:
client = await _client(tmp_path)
try:
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
)
run_id = created.json()["id"]
await _await_status(client, run_id, "awaiting_approval")
await client.post(f"/api/runs/{run_id}/approve")
await _await_status(client, run_id, "done")
collected = b""
async with client.stream("GET", f"/api/runs/{run_id}/events") as response:
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
async for chunk in response.aiter_bytes():
collected += chunk
if b"run_done" in collected:
break
text = collected.decode()
assert "event: run_started" in text
assert "event: run_done" in text
finally:
await client.aclose()
async def test_pause_resume_cancel_over_http(tmp_path: Path) -> None:
async with await _client(tmp_path) as client:
created = await client.post(
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
)
run_id = created.json()["id"]
await _await_status(client, run_id, "awaiting_approval")
paused = await client.post(f"/api/runs/{run_id}/pause")
assert paused.status_code == 200
resumed = await client.post(f"/api/runs/{run_id}/resume")
assert resumed.status_code == 200
cancelled = await client.post(f"/api/runs/{run_id}/cancel")
assert cancelled.status_code == 200
for _ in range(200):
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "cancelled":
break
await asyncio.sleep(0.01)
assert status == "cancelled"
-99
View File
@@ -1,99 +0,0 @@
import httpx
import pytest
from researchx.adapters.capability_probe import probe_model_adapter
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.probe import DEFAULT_CONTEXT_CAP, DEFAULT_EMBEDDING_BATCH
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.types import AdapterError
def _secrets(tmp_path, values: dict[str, str]) -> EnvFileSecretsProvider:
env_file = tmp_path / ".env.secrets"
env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8")
return EnvFileSecretsProvider(paths=[env_file])
def _ok_smoke_response() -> dict[str, object]:
return {
"choices": [{"message": {"role": "assistant", "content": '{"ok": true}'}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2},
}
async def test_probe_green_on_supporting_model(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("/models"):
return httpx.Response(200, json={"data": [{"id": "w"}, {"id": "p"}]})
return httpx.Response(200, json=_ok_smoke_response())
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
probe = await probe_model_adapter(adapter)
assert probe.endpoint == "https://ollama.com/v1"
assert probe.models == ["w", "p"]
assert probe.context_window == DEFAULT_CONTEXT_CAP
assert probe.embedding_batch == DEFAULT_EMBEDDING_BATCH
assert probe.structured_outputs_supported is True
assert probe.structured_output_warning is None
assert probe.degraded is False
async def test_probe_warns_on_weak_structured_output(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="writer-model")
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("/models"):
return httpx.Response(200, json={"data": [{"id": "writer-model"}]})
return httpx.Response(200, json={"choices": [{"message": {"content": "not json"}}]})
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
probe = await probe_model_adapter(adapter)
assert probe.structured_outputs_supported is False
assert probe.structured_output_warning is not None
assert "writer-model" in probe.structured_output_warning
assert "degrade" in probe.structured_output_warning
assert probe.degraded is True
async def test_probe_reports_endpoint_down_as_unsupported(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="boom")
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
with pytest.raises(AdapterError):
await probe_model_adapter(adapter)
async def test_list_models_falls_back_to_local(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).startswith("https://ollama.com"):
return httpx.Response(401, json={"error": "unauthorized"})
return httpx.Response(200, json={"data": [{"id": "local-model"}]})
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
models = await adapter.list_models()
assert models == ["local-model"]
assert adapter.active_base_url == "http://localhost:11434/v1"
assert adapter.last_fallback_reason is not None
-66
View File
@@ -1,66 +0,0 @@
import pytest
from pydantic import ValidationError
from researchx.config.dials import DIAL_REGISTRY, DialDescriptor, Dials
EXPECTED_DIALS = {
"source_universe": "web",
"search_routing": "single",
"query_strategy": "decomposed",
"breadth": 2,
"depth": 1,
"quality_gates": "standard",
"citation_rigor": "per-claim",
"verification_passes": 1,
"synthesis_style": "memo",
"output_structure": "brief",
"memory_scope": "none",
"checkpoints": False,
}
def test_defaults_resolve() -> None:
assert Dials().model_dump() == EXPECTED_DIALS
def test_all_twelve_dials_present() -> None:
assert set(Dials.model_fields) == set(EXPECTED_DIALS)
assert len(Dials.model_fields) == 12
def test_registry_covers_all_dials() -> None:
assert set(DIAL_REGISTRY) == set(EXPECTED_DIALS)
for descriptor in DIAL_REGISTRY.values():
assert isinstance(descriptor, DialDescriptor)
assert descriptor.label
assert descriptor.description
def test_literal_dial_rejects_invalid_value() -> None:
with pytest.raises(ValidationError):
Dials(source_universe="library")
def test_bounded_int_rejects_out_of_range() -> None:
with pytest.raises(ValidationError):
Dials(breadth=6)
with pytest.raises(ValidationError):
Dials(depth=0)
with pytest.raises(ValidationError):
Dials(verification_passes=-1)
def test_checkpoints_rejects_non_bool() -> None:
with pytest.raises(ValidationError):
Dials(checkpoints=5)
def test_model_dump_round_trips() -> None:
dials = Dials(depth=3, breadth=5, checkpoints=True, synthesis_style="executive")
assert Dials.model_validate(dials.model_dump()) == dials
def test_dials_are_immutable() -> None:
dials = Dials()
with pytest.raises(ValidationError):
dials.depth = 3
-183
View File
@@ -1,183 +0,0 @@
import asyncio
import json
from pathlib import Path
import pytest
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.engine.states import RunStateName, can_transition
from researchx.methodologies import registry
_PLAN = json.dumps(
{
"sub_questions": ["methodology verification", "quote anchoring"],
"source_universe": ["web"],
"outline": ["intro", "findings"],
}
)
_WRITER_REPORT = (
"# Report\n\n"
"The verification pass re-anchored every quoted claim [p-1]. "
"Methodology-first selection converts ad-hoc prompting [p-1] "
"into reproducible decisions [p-2].\n"
)
def _canned() -> list[ChatResult]:
return [
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER_REPORT, model="fake-writer"),
]
def _actor(tmp_path: Path) -> tuple[RunActor, EventBus, FakeSearchAdapter]:
_run_id, config = make_run_config(
question="How do verification pipelines keep citations honest?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
model = FakeModelAdapter(canned=_canned())
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=tmp_path)
bus = EventBus()
actor = RunActor(
run_config=config,
model=model,
search=search,
storage=storage,
registry=registry,
event_bus=bus,
)
return actor, bus, search
async def test_full_walk_reaches_done(tmp_path: Path) -> None:
actor, bus, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
if task.done():
break
await actor.approve()
state = await task
assert state.state == RunStateName.DONE
assert state.plan is not None and len(state.plan.sub_questions) == 2
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "DONE"
report = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "report.md")
assert "[p-1]" in report
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
"abcd2345", "ledger.json"
)
ledger = json.loads(ledger_raw)
assert ledger["checklist"]["citations_checked"] >= 1
assert ledger["checklist"]["liveness_ratio"] >= 0
checklist = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
"abcd2345", "checklist.md"
)
assert "Verification Checklist" in checklist
types = [e.type for e in bus.replay()]
assert "run_started" in types
assert "query_fired" in types
assert "source_visited" in types
assert "passage_extracted" in types
assert "run_done" in types
async def test_halt_at_awaiting_approval_until_approved(tmp_path: Path) -> None:
actor, _, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
for _ in range(100):
await asyncio.sleep(0.01)
if actor.state.state == RunStateName.AWAITING_APPROVAL:
break
assert actor.state.state == RunStateName.AWAITING_APPROVAL
await asyncio.sleep(0.05)
assert not task.done()
await actor.approve()
state = await task
assert state.state == RunStateName.DONE
async def test_cancel_persists_cancelled_state(tmp_path: Path) -> None:
actor, _, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
actor.cancel()
await actor.approve()
with pytest.raises(asyncio.CancelledError):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "CANCELLED"
async def test_pause_resume_mid_run(tmp_path: Path) -> None:
actor, _, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
await actor.approve()
actor.pause()
await asyncio.sleep(0.05)
assert not task.done()
actor.resume()
state = await task
assert state.state == RunStateName.DONE
def test_illegal_transitions_rejected() -> None:
assert not can_transition(RunStateName.PLANNING, RunStateName.DONE)
assert not can_transition(RunStateName.DONE, RunStateName.PLANNING)
assert can_transition(RunStateName.PLANNING, RunStateName.AWAITING_APPROVAL)
assert can_transition(RunStateName.REFLECTING, RunStateName.SEARCHING)
async def test_planner_non_json_fails_honestly(tmp_path: Path) -> None:
_run_id, config = make_run_config(
question="q",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
model = FakeModelAdapter(canned=[ChatResult(content="not json", model="fake-planner")])
actor = RunActor(
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
with pytest.raises(Exception, match="planner returned non-JSON plan"):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "FAILED"
async def test_writer_context_is_visited_only(tmp_path: Path) -> None:
actor, _, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
await actor.approve()
await task
assert actor._store.writer_context(), "evidence collected"
outside_ids = {p.passage_id for p in actor._store.writer_context()}
assert all(pid.startswith("p-") for pid in outside_ids)
-56
View File
@@ -1,56 +0,0 @@
from researchx.evidence.store import EvidenceStore, extract_passages
def test_extract_passages_min_length() -> None:
text = "short\n" + ("x" * 150) + "\nanother short"
passages = extract_passages(text)
assert passages == ["x" * 150]
def test_store_stable_ids() -> None:
store = EvidenceStore()
first = store.add_passage("https://a", "first passage text " * 10)
second = store.add_passage("https://a", "second passage text " * 10)
assert first.passage_id == "p-1"
assert second.passage_id == "p-2"
def test_store_add_page_creates_passages() -> None:
store = EvidenceStore()
body = "\n".join(f"paragraph {i} " + "y" * 150 for i in range(3))
store.add_page("https://example.org/a", "Title A", body)
assert len(store) == 3
page = store.pages()[0]
assert page.url == "https://example.org/a"
assert page.title == "Title A"
assert len(page.passage_ids) == 3
def test_writer_context_contains_only_stored() -> None:
store = EvidenceStore()
store.add_passage("https://a", "stored passage " * 20)
context = store.writer_context()
assert len(context) == 1
assert context[0].url == "https://a"
outside = EvidenceStore()
outside_context = outside.writer_context()
assert outside_context == []
def test_dedupe_by_quote() -> None:
store = EvidenceStore()
text = "identical passage text " * 10
store.add_passage("https://a", text)
assert store.has_seen(text[:200])
assert not store.has_seen("something never stored " * 20)
def test_passages_for_url() -> None:
store = EvidenceStore()
store.add_passage("https://a", "from a " * 30)
store.add_passage("https://b", "from b " * 30)
from_a = store.passages_for_url("https://a")
assert len(from_a) == 1
assert from_a[0].url == "https://a"
assert store.passages_for_url("https://missing") == []
-82
View File
@@ -1,82 +0,0 @@
import inspect
from researchx.adapters import fakes
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter, corpus
from researchx.adapters.model import ModelAdapter, ModelRoles
from researchx.adapters.search import SearchAdapter
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
def _model() -> FakeModelAdapter:
return FakeModelAdapter(canned=[ChatResult(content="ok", model="fake-model")])
async def test_fake_model_full_interface() -> None:
model = _model()
assert isinstance(model, ModelAdapter)
result = await model.chat([ModelMessage(role="user", content="hi")], model="fake-model")
assert result.content == "ok"
assert await model.list_models() == ["fake-model"]
assert model.roles() == ModelRoles(planner="fake-planner", writer="fake-writer")
async def test_fake_model_exhausted_raises() -> None:
model = _model()
await model.chat([ModelMessage(role="user", content="hi")], model="fake-model")
try:
await model.chat([ModelMessage(role="user", content="again")], model="fake-model")
except AdapterError:
pass
else:
raise AssertionError("expected AdapterError")
async def test_fake_model_json_mode_recorded() -> None:
model = _model()
await model.chat([ModelMessage(role="user", content="hi")], model="fake-model", json_mode=True)
assert model.last_json_mode is True
async def test_fake_search_full_interface() -> None:
search = FakeSearchAdapter(corpus())
assert isinstance(search, SearchAdapter)
results = await search.search("verification quoted claim", max_results=3)
assert 0 < len(results) <= 3
page = await search.fetch(results[0].url)
assert page.url == results[0].url
async def test_fake_search_deterministic_order() -> None:
search = FakeSearchAdapter(corpus())
first = await search.search("methodology", max_results=5)
second = await search.search("methodology", max_results=5)
assert [r.url for r in first] == [r.url for r in second]
async def test_fake_search_unknown_url_raises() -> None:
search = FakeSearchAdapter(corpus())
try:
await search.fetch("https://example.org/missing")
except AdapterError:
pass
else:
raise AssertionError("expected AdapterError")
def test_corpus_shape() -> None:
pages = CORPUS
assert len(pages) >= 5
urls = [p.url for p in pages]
assert len(set(urls)) == len(urls)
texts = "\n".join(p.text for p in pages)
assert "—" in texts or "é" in texts or "近" in texts
def test_corpus_shared_sentence() -> None:
sharing = [p for p in CORPUS if fakes.SHARED_SENTENCE in p.text]
assert len(sharing) == 2
def test_fakes_no_httpx_dependency() -> None:
source = inspect.getsource(fakes)
assert "httpx" not in source
-84
View File
@@ -1,84 +0,0 @@
from datetime import UTC, datetime
import pytest
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.config.run_config import RunConfig, UnknownOverrideError, new_run_id, resolve_dials
from researchx.methodologies import registry
async def test_registry_to_fake_model_to_snapshot_round_trip(tmp_path) -> None:
methodology = registry.get("deep-report")
canned = ChatResult(
content='{"sub_questions": ["What is verification?", "How does anchoring work?"]}',
model="fake-planner",
)
model = FakeModelAdapter(canned=[canned])
plan_result = await model.chat([], model=model.roles().planner, json_mode=True)
assert "sub_questions" in plan_result.content
run_id = new_run_id()
dials = resolve_dials("deep-report", {"breadth": 3}, registry)
run_config = RunConfig(
run_id=run_id,
methodology_slug="deep-report",
question="How do verification pipelines keep citations honest?",
resolved_dials=dials,
overrides={"breadth": 3},
created_at=datetime.now(UTC),
)
assert run_config.resolved_dials.breadth == 3
storage = LocalStorageAdapter(base_dir=tmp_path)
await storage.write_run_artifact(run_id, "run_config.json", run_config.model_dump_json())
restored = RunConfig.model_validate_json(
await storage.read_run_artifact(run_id, "run_config.json")
)
assert restored == run_config
assert restored.resolved_dials.depth == methodology.dials.depth
def test_methodology_behavior_difference() -> None:
rapid = registry.get("rapid-answer")
deep = registry.get("deep-report")
assert rapid.dials.depth < deep.dials.depth
assert rapid.dials.breadth < deep.dials.breadth
assert rapid.output_shape != deep.output_shape
assert rapid.dials.synthesis_style != deep.dials.synthesis_style
assert rapid.dials.verification_passes < deep.dials.verification_passes
assert rapid.dials.output_structure != deep.dials.output_structure
def test_registry_snapshot_immutability() -> None:
from pydantic import ValidationError
methodology = registry.get("rapid-answer")
frozen = registry.get("rapid-answer")
with pytest.raises(ValidationError):
frozen.dials.depth = 99
fresh = registry.get("rapid-answer")
assert fresh.dials.depth == methodology.dials.depth
assert fresh.dials.depth == 1
def test_override_rejects_unknown_dial() -> None:
with pytest.raises(UnknownOverrideError):
resolve_dials("rapid-answer", {"nonexistent_dial": 1}, registry)
def test_new_run_id_format() -> None:
run_id = new_run_id()
assert len(run_id) == 8
assert all(c in "0123456789abcdef" for c in run_id)
async def test_fake_search_feeds_evidence_shape() -> None:
search = FakeSearchAdapter(CORPUS)
results = await search.search("methodology dials verification", max_results=5)
assert len(results) >= 1
for result in results:
page = await search.fetch(result.url)
assert page.text.strip()
-64
View File
@@ -1,64 +0,0 @@
"""Opt-in live smoke tier (G-004): real-adapter probes, keyed only.
These tests NEVER run in the zero-key CI suite: they are marked
`live` and skip cleanly when OLLAMA_API_KEY / TAVILY_API_KEY are
absent from the secrets scope. Run explicitly with:
uv run pytest -m live
"""
import os
from pathlib import Path
import httpx
import pytest
from researchx.adapters.capability_probe import probe_model_adapter
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter
from researchx.trust.liveness import LivenessStatus, check_liveness
pytestmark = pytest.mark.live
_SECRETS_PATHS = [
Path.cwd() / ".ciagent" / ".env.secrets",
Path.cwd().parents[1] / ".ciagent" / ".env.secrets",
]
def _secrets() -> EnvFileSecretsProvider:
return EnvFileSecretsProvider(paths=_SECRETS_PATHS)
def _needs_key(key: str) -> str:
value = _secrets().resolve(key) or os.environ.get(key)
if not value:
pytest.skip(f"{key} not present — live tier is opt-in")
return value
async def test_live_ollama_probe_and_chat() -> None:
_needs_key("OLLAMA_API_KEY")
adapter = OllamaModelAdapter(
_secrets(), planner_model="gpt-oss:20b", writer_model="gpt-oss:20b"
)
probe = await probe_model_adapter(adapter)
assert probe.endpoint in ("https://ollama.com/v1", "http://localhost:11434/v1")
assert probe.context_window <= 8192
assert probe.embedding_batch == 8
async def test_live_tavily_search() -> None:
_needs_key("TAVILY_API_KEY")
adapter = TavilySearchAdapter(_secrets())
results = await adapter.search("deep research agent verification", max_results=3)
assert 1 <= len(results) <= 3
assert all(r.url.startswith("http") for r in results)
async def test_live_liveness_against_real_page() -> None:
status = await check_liveness("https://example.org/")
assert status in (LivenessStatus.LIVE, LivenessStatus.STALE, LivenessStatus.DEAD)
void_httpx = httpx.AsyncClient
del void_httpx
-66
View File
@@ -1,66 +0,0 @@
from pathlib import Path
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.storage import StorageAdapter
from researchx.adapters.types import AdapterError
async def test_storage_round_trip(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
assert isinstance(storage, StorageAdapter)
target = await storage.write_run_artifact("abcd2345", "state.json", '{"state": "PLANNING"}')
assert target.exists()
content = await storage.read_run_artifact("abcd2345", "state.json")
assert content == '{"state": "PLANNING"}'
async def test_atomic_overwrite(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 2}')
assert await storage.read_run_artifact("abcd2345", "state.json") == '{"v": 2}'
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
assert leftovers == []
async def test_run_dir_layout(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
run_dir = storage.run_dir("abcd2345")
assert run_dir == tmp_path / "runs" / "abcd2345"
await storage.write_run_artifact("abcd2345", "report.md", "# Report")
assert run_dir.is_dir()
async def test_read_missing_raises(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
try:
await storage.read_run_artifact("zzzz9999", "state.json")
except AdapterError:
pass
else:
raise AssertionError("expected AdapterError")
async def test_path_traversal_rejected(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
for bad_name in ["../escape.md", "/etc/passwd", "a/b.md", "", "."]:
try:
await storage.write_run_artifact("abcd2345", bad_name, "x")
except AdapterError:
pass
else:
raise AssertionError(f"expected AdapterError for name: {bad_name!r}")
for bad_run_id in ["../evil", "short", "waytoolongid123", "ABCD2345", "a b c d e"]:
try:
await storage.write_run_artifact(bad_run_id, "state.json", "x")
except AdapterError:
pass
else:
raise AssertionError(f"expected AdapterError for run_id: {bad_run_id!r}")
async def test_no_tmp_leftover_after_write(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
assert leftovers == []
-95
View File
@@ -1,95 +0,0 @@
import pytest
from pydantic import ValidationError
from researchx.methodologies import registry
def test_registry_resolves_both_slugs() -> None:
rapid = registry.get("rapid-answer")
deep = registry.get("deep-report")
assert rapid.slug == "rapid-answer"
assert deep.slug == "deep-report"
assert rapid.name == "Rapid Answer"
assert deep.name == "Deep Report"
def test_unknown_slug_raises_key_error() -> None:
with pytest.raises(KeyError):
registry.get("systematic-review")
def test_list_ordered_by_name() -> None:
listed = registry.list()
assert [m.name for m in listed] == ["Deep Report", "Rapid Answer"]
def test_builtin_dials_match_contracts() -> None:
rapid = registry.get("rapid-answer")
assert rapid.dials.depth == 1
assert rapid.dials.breadth == 1
assert rapid.dials.verification_passes == 1
assert rapid.dials.citation_rigor == "per-claim"
assert rapid.dials.synthesis_style == "memo"
assert rapid.dials.output_structure == "brief"
assert rapid.output_shape == "memo"
deep = registry.get("deep-report")
assert deep.dials.depth == 3
assert deep.dials.breadth == 2
assert deep.dials.verification_passes == 2
assert deep.dials.citation_rigor == "per-claim"
assert deep.dials.synthesis_style == "executive"
assert deep.dials.output_structure == "report"
assert deep.dials.checkpoints is True
assert deep.output_shape == "executive-report"
def test_methodologies_differ_meaningfully() -> None:
rapid = registry.get("rapid-answer")
deep = registry.get("deep-report")
assert rapid.dials.depth != deep.dials.depth
assert rapid.dials.breadth != deep.dials.breadth
assert rapid.dials.verification_passes != deep.dials.verification_passes
assert rapid.dials.synthesis_style != deep.dials.synthesis_style
assert rapid.dials.output_structure != deep.dials.output_structure
assert rapid.output_shape != deep.output_shape
def test_registry_entries_are_immutable() -> None:
snapshot = registry.get("rapid-answer")
with pytest.raises(ValidationError):
snapshot.dials.depth = 3
assert registry.get("rapid-answer").dials.depth == 1
listed = registry.list()
with pytest.raises(ValidationError):
listed[0].dials.checkpoints = True
assert registry.get("rapid-answer").dials.checkpoints is False
def test_prompt_templates_render_placeholders() -> None:
deep = registry.get("deep-report")
planner = deep.render_planner_prompt(question="What changed?", context="none")
assert "What changed?" in planner
assert "none" in planner
writer = deep.render_writer_prompt(
question="What changed?", context="none", plan="p1", evidence="[p-1] (https://a) quote text"
)
assert "{plan}" not in writer
assert "{evidence}" not in writer
assert "p1" in writer
assert "[p-1] (https://a) quote text" in writer
def test_writer_prompt_requires_evidence_slot() -> None:
for slug in ("rapid-answer", "deep-report"):
methodology = registry.get(slug)
assert "{evidence}" in methodology.writer_prompt
def test_mutation_of_returned_copy_does_not_affect_registry() -> None:
original = registry.get("deep-report").dials.model_dump()
copy = registry.get("deep-report").dials.model_copy()
mutated = copy.model_copy(update={"depth": 1})
assert mutated.depth == 1
assert registry.get("deep-report").dials.model_dump() == original
-215
View File
@@ -1,215 +0,0 @@
import asyncio
import json
from datetime import UTC, datetime
from pathlib import Path
import httpx
import pytest
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.evidence.store import EvidenceStore
from researchx.methodologies import registry
from researchx.trust.anchoring import anchor_quote, extract_quotes_from_report
class RecordingModel:
def __init__(self, canned: list[ChatResult]) -> None:
self._canned = list(canned)
self.prompts: list[str] = []
async def chat(
self, messages: list[ModelMessage], *, model: str, temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
self.prompts.append(messages[0].content)
return self._canned.pop(0)
async def list_models(self) -> list[str]:
return ["fake-model"]
def roles(self):
from researchx.adapters.model import ModelRoles
return ModelRoles(planner="fake-planner", writer="fake-writer")
_PLAN = json.dumps({"sub_questions": ["verification"], "source_universe": ["web"], "outline": []})
async def test_evidence_reaches_writer_prompt(tmp_path: Path) -> None:
_rid, config = make_run_config(
question="q", context="", methodology_slug="rapid-answer",
overrides={}, registry=registry, run_id="abcd2345",
)
model = RecordingModel(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content="# Report\n\nClaim [p-1].\n", model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=model, # type: ignore[arg-type]
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
while actor.state.state == RunStateName.PLANNING:
await asyncio.sleep(0.01)
await actor.approve()
await task
writer_prompt = model.prompts[-1]
assert "[p-1]" in writer_prompt
assert "(https://example.org/" in writer_prompt
assert "Evidence passages:" in writer_prompt
async def test_engine_refuses_private_urls_at_extract(tmp_path: Path, monkeypatch) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("TAVILY_API_KEY=t-key\n", encoding="utf-8")
secrets = EnvFileSecretsProvider(paths=[env_file])
tavily = TavilySearchAdapter(secrets)
called_urls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
called_urls.append(str(request.url))
return httpx.Response(200, text="<html><body>never</body></html>")
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=transport, **kw))
with pytest.raises(AdapterError, match="fetch refused"):
await tavily.fetch("http://169.254.169.254/latest/meta-data")
assert called_urls == []
async def test_resume_from_searching_mid_run(tmp_path: Path) -> None:
_rid, config = make_run_config(
question="q", context="", methodology_slug="rapid-answer",
overrides={}, registry=registry, run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
mid_state = RunState(
run_id="abcd2345",
question="q",
methodology_slug="rapid-answer",
state=RunStateName.SEARCHING,
plan=Plan(sub_questions=["verification"], source_universe=["web"], outline=[]),
depth_cursor=0,
depth_target=1,
)
await storage.write_run_artifact("abcd2345", "state.json", mid_state.model_dump_json())
model = RecordingModel(
canned=[
ChatResult(content="# Report\n\nClaim [p-1].\n", model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=model, # type: ignore[arg-type]
search=FakeSearchAdapter(CORPUS),
storage=storage,
registry=registry,
event_bus=EventBus(),
)
actor.state = RunState.model_validate_json(
await storage.read_run_artifact("abcd2345", "state.json")
)
state = await actor.run()
assert state.state == RunStateName.DONE
async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
_rid, config = make_run_config(
question="q", context="", methodology_slug="rapid-answer",
overrides={}, registry=registry, run_id="abcd2345",
)
model = FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content="# Report\n\nClaim [p-1].\n", model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
while actor.state.state == RunStateName.PLANNING:
await asyncio.sleep(0.01)
await actor.approve()
actor.pause()
actor.cancel()
with pytest.raises(asyncio.CancelledError):
await task
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
)
assert saved["state"] == "CANCELLED"
def test_quote_mismatch_flagged_in_ledger_path() -> None:
store = EvidenceStore()
real_passage = store.add_passage("https://a", "The verified sentence about anchoring " * 5)
hallucinated = "This quote was never in any stored passage at all whatsoever."
verdict = anchor_quote(hallucinated, [real_passage])
assert verdict.matched is False
assert verdict.weak is False
good = anchor_quote(real_passage.quote, [real_passage])
assert good.matched is True
report = f"Claim [p-1].\n\n> {hallucinated} [p-1]\n"
quotes = extract_quotes_from_report(report, store)
assert len(quotes) == 2
bad_verdict = anchor_quote(hallucinated, store.writer_context())
assert bad_verdict.matched is False
def test_ledger_export_gate() -> None:
from researchx.trust.ledger import build_ledger, liveness_export_gate
from researchx.trust.liveness import LivenessStatus
store = EvidenceStore()
stored_text = "The verified sentence used for the export gate test."
store.add_passage("https://a", stored_text)
ledger = build_ledger(
run_id="abcd2345",
report_text=f"{stored_text} [p-1]",
evidence=store,
liveness={"https://a": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
passed, failures = liveness_export_gate(ledger)
assert passed is True
assert failures == []
ledger_dead = build_ledger(
run_id="abcd2345",
report_text=f"{stored_text} [p-1]",
evidence=store,
liveness={"https://a": LivenessStatus.DEAD},
visited_at=datetime.now(UTC),
)
passed, failures = liveness_export_gate(ledger_dead)
assert passed is False
assert failures == ["https://a"]
-129
View File
@@ -1,129 +0,0 @@
import asyncio
import json
from pathlib import Path
import httpx
import pytest
from researchx.adapters.capability_probe import probe_model_adapter
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.types import ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.engine.states import RunStateName
from researchx.methodologies import registry
async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("OLLAMA_API_KEY=cloud-key\n", encoding="utf-8")
secrets = EnvFileSecretsProvider(paths=[env_file])
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("/models"):
return httpx.Response(200, json={"data": [{"id": "w"}]})
return httpx.Response(
200, json={"choices": [{"message": {"content": '{"ok": true}'}}]}
)
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=transport, **kw))
probe = await probe_model_adapter(adapter)
assert probe.structured_outputs_supported is True
_rid, config = make_run_config(
question="q",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
plan = json.dumps({"sub_questions": ["verification"], "source_universe": ["web"], "outline": []})
writer = "# Report\n\nClaim [p-1].\n"
fake_model = FakeModelAdapter(
canned=[
ChatResult(content=plan, model="fake-planner"),
ChatResult(content=writer, model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=EventBus(),
)
await actor.attach_probe(probe.model_dump(mode="json"))
assert actor.state.probe is not None
assert actor.state.probe["structured_outputs_supported"] is True
task = asyncio.create_task(actor.run())
while actor.state.state == RunStateName.PLANNING:
await asyncio.sleep(0.01)
await actor.approve()
state = await task
assert state.state == RunStateName.DONE
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
)
assert saved["probe"]["endpoint"] == "https://ollama.com/v1"
async def test_probe_failure_never_crashes_the_run(tmp_path: Path, monkeypatch) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("OLLAMA_API_KEY=cloud-key\n", encoding="utf-8")
secrets = EnvFileSecretsProvider(paths=[env_file])
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="down")
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=transport, **kw))
from researchx.adapters.types import AdapterError
with pytest.raises(AdapterError):
await probe_model_adapter(adapter)
_rid, config = make_run_config(
question="q",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
plan = json.dumps({"sub_questions": ["verification"], "source_universe": ["web"], "outline": []})
writer = "# Report\n\nClaim [p-1].\n"
fake_model = FakeModelAdapter(
canned=[
ChatResult(content=plan, model="fake-planner"),
ChatResult(content=writer, model="fake-writer"),
]
)
bus = EventBus()
actor = RunActor(
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=bus,
)
task = asyncio.create_task(actor.run())
while actor.state.state == RunStateName.PLANNING:
await asyncio.sleep(0.01)
await actor.approve()
state = await task
assert state.state == RunStateName.DONE
assert state.probe is None
-203
View File
@@ -1,203 +0,0 @@
import httpx
import pytest
from researchx.adapters.duckduckgo import DuckDuckGoSearchAdapter
from researchx.adapters.model import ModelRoles
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.probe import CapabilityProbe, cap_context_window, structured_output_warning
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter
from researchx.adapters.types import AdapterError, ModelMessage
def _secrets(tmp_path, values: dict[str, str]) -> EnvFileSecretsProvider:
env_file = tmp_path / ".env.secrets"
env_file.write_text(
"".join(f"{k}={v}\n" for k, v in values.items()),
encoding="utf-8",
)
return EnvFileSecretsProvider(paths=[env_file])
def _chat_payload_ok() -> dict[str, object]:
return {
"choices": [{"message": {"role": "assistant", "content": '{"ok": true}'}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
}
async def test_ollama_adapter_uses_cloud_when_keyed(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
calls: list[tuple[str, dict[str, str]]] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append((str(request.url), dict(request.headers)))
return httpx.Response(200, json=_chat_payload_ok())
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
result = await adapter.chat([ModelMessage(role="user", content="hi")], model="w")
assert result.content == '{"ok": true}'
assert calls[0][0].startswith("https://ollama.com/v1")
assert "Bearer cloud-key" in calls[0][1]["authorization"]
async def test_ollama_adapter_falls_back_to_local_on_cloud_failure(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
bases_hit: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
bases_hit.append(url)
if url.startswith("https://ollama.com"):
return httpx.Response(401, json={"error": "bad key"})
return httpx.Response(200, json=_chat_payload_ok())
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
result = await adapter.chat([ModelMessage(role="user", content="hi")], model="w")
assert result.content == '{"ok": true}'
assert any(u.startswith("http://localhost:11434/v1") for u in bases_hit)
assert adapter.last_fallback_reason is not None
assert adapter.active_base_url == "http://localhost:11434/v1"
async def test_ollama_roles(tmp_path) -> None:
secrets = _secrets(tmp_path, {})
adapter = OllamaModelAdapter(secrets, planner_model="planner-model", writer_model="writer-model")
assert adapter.roles() == ModelRoles(planner="planner-model", writer="writer-model")
def test_context_cap() -> None:
assert cap_context_window(None) == 8192
assert cap_context_window(100_000) == 8192
assert cap_context_window(4_096) == 4_096
assert cap_context_window(0) == 8192
def test_structured_output_warning_contract() -> None:
assert structured_output_warning(True, "m") is None
warning = structured_output_warning(False, "writer-model")
assert warning is not None
assert "writer-model" in warning
assert "degrade" in warning
probe = CapabilityProbe(
endpoint="http://localhost:11434/v1",
models=["m"],
context_window=8192,
embedding_batch=8,
structured_outputs_supported=False,
structured_output_warning=warning,
)
assert probe.degraded
async def test_tavily_requires_key(tmp_path) -> None:
secrets = _secrets(tmp_path, {})
with pytest.raises(AdapterError):
TavilySearchAdapter(secrets)
async def test_tavily_search_returns_results(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"TAVILY_API_KEY": "t-key"})
def handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content.decode())
assert body["api_key"] == "t-key"
return httpx.Response(
200,
json={
"results": [
{"title": "T1", "url": "https://example.org/a", "content": "snippet one"},
{"title": "T2", "url": "https://example.org/b", "content": "snippet two"},
]
},
)
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
adapter = TavilySearchAdapter(secrets)
results = await adapter.search("deep research", max_results=2)
assert [r.title for r in results] == ["T1", "T2"]
async def test_ddg_parses_html_results(monkeypatch) -> None:
html = """
<div class="results">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fx&amp;rut=abc">Example One</a>
<a class="result__snippet" href="#">First snippet text</a>
<a rel="nofollow" class="result__a" href="https://example.org/y">Example Two</a>
<a class="result__snippet" href="#">Second snippet text</a>
</div>
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=html)
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
adapter = DuckDuckGoSearchAdapter()
results = await adapter.search("anything", max_results=8)
assert [r.url for r in results] == ["https://example.org/x", "https://example.org/y"]
assert results[0].snippet == "First snippet text"
assert adapter.last_degradation is None
async def test_ddg_rate_limit_degrades_with_typed_warning(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(403, text="forbidden")
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
adapter = DuckDuckGoSearchAdapter()
results = await adapter.search("anything")
assert results == []
assert adapter.last_degradation is not None
assert "rate-limited" in adapter.last_degradation
async def test_ddg_fetch_extracts_text(monkeypatch) -> None:
html = "<html><head><title>Page Title</title></head><body><script>var x=1;</script><p>Hello world</p></body></html>"
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=html)
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
adapter = DuckDuckGoSearchAdapter()
page = await adapter.fetch("https://example.org/x")
assert page.title == "Page Title"
assert "Hello world" in page.text
assert "var x" not in page.text
def test_resolve_search_adapter_prefers_tavily(tmp_path) -> None:
from researchx.adapters.duckduckgo import resolve_search_adapter
keyed = _secrets(tmp_path, {"TAVILY_API_KEY": "t-key"})
adapter, name = resolve_search_adapter(keyed)
assert name == "tavily"
assert isinstance(adapter, TavilySearchAdapter)
keyless = _secrets(tmp_path, {})
adapter, name = resolve_search_adapter(keyless)
assert name == "duckduckgo-keyless"
assert isinstance(adapter, DuckDuckGoSearchAdapter)
-105
View File
@@ -1,105 +0,0 @@
import json
from pathlib import Path
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.methodologies import registry
_PLAN = json.dumps(
{
"sub_questions": ["verification passes"],
"source_universe": ["web"],
"outline": ["intro"],
}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
def test_run_id_generator_is_the_storage_one() -> None:
from researchx.engine.actor import make_run_config as mrc
run_id, _ = mrc(
question="q", context="", methodology_slug="rapid-answer", overrides={}, registry=registry
)
assert len(run_id) == 8
alphabet = set("abcdefghjkmnpqrstuvwxyz23456789")
assert all(c in alphabet for c in run_id)
async def test_state_file_round_trip_enables_restart(tmp_path: Path) -> None:
run_id, config = make_run_config(
question="How do engines resume?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
state = RunState(
run_id=run_id,
question=config.question,
methodology_slug="rapid-answer",
state=RunStateName.SEARCHING,
depth_cursor=1,
depth_target=3,
)
await storage.write_run_artifact(run_id, "state.json", state.model_dump_json())
restored = RunState.model_validate_json(
await storage.read_run_artifact(run_id, "state.json")
)
assert restored.depth_cursor == 1
assert restored.depth_target == 3
assert restored.state == RunStateName.SEARCHING
async def test_actor_resumes_from_restored_state(tmp_path: Path) -> None:
run_id, config = make_run_config(
question="How do engines resume?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
mid_state = RunState(
run_id=run_id,
question=config.question,
methodology_slug="rapid-answer",
state=RunStateName.AWAITING_APPROVAL,
depth_cursor=0,
depth_target=1,
plan=Plan(sub_questions=["verification passes"], source_universe=["web"], outline=["intro"]),
)
await storage.write_run_artifact(run_id, "state.json", mid_state.model_dump_json())
model = FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=storage,
registry=registry,
event_bus=EventBus(),
)
restored = RunState.model_validate_json(
await storage.read_run_artifact(run_id, "state.json")
)
actor.state = restored
await actor.approve()
state = await actor.run()
assert state.state == RunStateName.DONE
assert state.plan is not None
assert state.plan.sub_questions == ["verification passes"]
-96
View File
@@ -1,96 +0,0 @@
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from researchx.config.dials import Dials
from researchx.config.run_config import (
RunConfig,
UnknownOverrideError,
new_run_id,
resolve_dials,
)
from researchx.methodologies import registry
def test_resolve_applies_overrides_on_top_of_bundle() -> None:
resolved = resolve_dials("deep-report", {"depth": 1, "checkpoints": False}, registry)
assert resolved.depth == 1
assert resolved.checkpoints is False
assert resolved.breadth == 2
assert resolved.verification_passes == 2
def test_resolve_empty_overrides_returns_bundle() -> None:
resolved = resolve_dials("rapid-answer", {}, registry)
assert resolved == registry.get("rapid-answer").dials
def test_resolve_rejects_unknown_override_keys() -> None:
with pytest.raises(UnknownOverrideError):
resolve_dials("rapid-answer", {"temperature": 0.7}, registry)
def test_resolve_rejects_invalid_override_values() -> None:
with pytest.raises(ValidationError):
resolve_dials("rapid-answer", {"depth": 9}, registry)
with pytest.raises(ValidationError):
resolve_dials("deep-report", {"synthesis_style": "poetic"}, registry)
def test_resolve_does_not_mutate_registry_bundle() -> None:
resolve_dials("deep-report", {"depth": 1}, registry)
assert registry.get("deep-report").dials.depth == 3
def _run_config(**overrides: object) -> RunConfig:
return RunConfig(
run_id=new_run_id(),
methodology_slug="deep-report",
question="What changed in the market?",
context="Q3 earnings season",
resolved_dials=resolve_dials("deep-report", overrides, registry),
created_at=datetime.now(UTC),
overrides=dict(overrides),
)
def test_run_config_accepts_valid_id() -> None:
config = _run_config(depth=2)
assert config.run_id == "abcd1234" or len(config.run_id) == 8
def test_run_config_rejects_invalid_run_id() -> None:
with pytest.raises(ValidationError):
RunConfig(
run_id="short",
methodology_slug="rapid-answer",
question="q",
resolved_dials=Dials(),
created_at=datetime.now(UTC),
)
def test_run_config_rejects_empty_question() -> None:
with pytest.raises(ValidationError):
RunConfig(
run_id=new_run_id(),
methodology_slug="rapid-answer",
question="",
resolved_dials=Dials(),
created_at=datetime.now(UTC),
)
def test_run_config_snapshot_round_trips_json() -> None:
config = _run_config(depth=2, checkpoints=False)
restored = RunConfig.model_validate_json(config.model_dump_json())
assert restored == config
assert restored.resolved_dials.depth == 2
assert restored.overrides == {"depth": 2, "checkpoints": False}
def test_run_config_is_immutable() -> None:
config = _run_config()
with pytest.raises(ValidationError):
config.question = "different"
-46
View File
@@ -1,46 +0,0 @@
from pathlib import Path
from researchx.adapters.secrets import EnvFileSecretsProvider, SecretsProvider
def test_secrets_provider_conformance(tmp_path: Path) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("OLLAMA_API_KEY=test-key-value\n", encoding="utf-8")
provider = EnvFileSecretsProvider(paths=[env_file])
assert isinstance(provider, SecretsProvider)
assert provider.resolve("OLLAMA_API_KEY") == "test-key-value"
assert provider.resolve("MISSING_VAR") is None
def test_secrets_later_file_wins(tmp_path: Path) -> None:
first = tmp_path / ".env"
second = tmp_path / ".env.secrets"
first.write_text("TAVILY_API_KEY=first-key\nOLLAMA_API_KEY=ollama-first\n", encoding="utf-8")
second.write_text("OLLAMA_API_KEY=ollama-second\n", encoding="utf-8")
provider = EnvFileSecretsProvider(paths=[first, second])
assert provider.resolve("TAVILY_API_KEY") == "first-key"
assert provider.resolve("OLLAMA_API_KEY") == "ollama-second"
def test_secrets_parse_skips_comments_and_blanks(tmp_path: Path) -> None:
env_file = tmp_path / ".env"
env_file.write_text(
"# comment line\n\nGITEA_TOKEN='quoted-token'\nBROKEN_LINE_NO_EQUALS\n",
encoding="utf-8",
)
provider = EnvFileSecretsProvider(paths=[env_file])
assert provider.resolve("GITEA_TOKEN") == "quoted-token"
assert provider.resolve("BROKEN_LINE_NO_EQUALS") is None
def test_secrets_repr_never_leaks_values(tmp_path: Path) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("OLLAMA_API_KEY=super-secret-value-123\n", encoding="utf-8")
provider = EnvFileSecretsProvider(paths=[env_file])
assert "super-secret-value-123" not in repr(provider)
assert "super-secret-value-123" not in str(provider)
def test_secrets_missing_file_ignored(tmp_path: Path) -> None:
provider = EnvFileSecretsProvider(paths=[tmp_path / ".env.absent"])
assert provider.resolve("ANY_KEY") is None
-5
View File
@@ -1,5 +0,0 @@
import researchx
def test_package_imports() -> None:
assert researchx.__name__ == "researchx"
-159
View File
@@ -1,159 +0,0 @@
from datetime import UTC, datetime
import httpx
from researchx.adapters.fakes import CORPUS, SHARED_SENTENCE
from researchx.evidence.store import EvidenceStore
from researchx.trust.anchoring import anchor_quote, normalize
from researchx.trust.ledger import build_ledger, export_checklist
from researchx.trust.liveness import LivenessStatus, check_liveness, liveness_report, url_allowed
def test_normalize_pipeline() -> None:
assert normalize(" Foo Bar ") == "foo bar"
assert normalize("Café") == normalize("café")
def test_anchor_exact_substring_short_circuits() -> None:
store = EvidenceStore()
passage = store.add_passage("https://a", SHARED_SENTENCE)
verdict = anchor_quote(SHARED_SENTENCE, [passage])
assert verdict.matched is True
assert verdict.ratio == 1.0
assert verdict.weak is False
def test_anchor_weak_match_flagged() -> None:
store = EvidenceStore()
passage = store.add_passage("https://a", SHARED_SENTENCE)
mutated = SHARED_SENTENCE.replace(
"The verification pass re-anchored every quoted claim",
"A liveness check validated the cited evidence",
)
verdict = anchor_quote(mutated, [passage])
assert verdict.ratio < 0.85
def test_anchor_high_fuzzy_match() -> None:
store = EvidenceStore()
passage = store.add_passage("https://a", "The pipeline verifies every quoted claim against its stored passage before shipping.")
near = "The pipeline verify every quoted claim against its stored passage before shipping."
verdict = anchor_quote(near, [passage])
assert verdict.ratio >= 0.85
assert verdict.matched is True
def test_anchor_no_match_returns_unmatched() -> None:
store = EvidenceStore()
passage = store.add_passage("https://a", SHARED_SENTENCE)
verdict = anchor_quote("completely unrelated text about something else entirely", [passage])
assert verdict.matched is False
assert verdict.weak is False
assert verdict.passage_id is None
def test_url_guard_blocks_private_and_schemes() -> None:
assert url_allowed("http://169.254.169.254/latest/meta-data") is False
assert url_allowed("http://localhost:8000/api") is False
assert url_allowed("http://127.0.0.1/x") is False
assert url_allowed("http://10.0.0.1/x") is False
assert url_allowed("file:///etc/passwd") is False
assert url_allowed("ftp://example.org/x") is False
assert url_allowed("not a url") is False
async def test_liveness_classification_live(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="alive page")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, follow_redirects=True) as client:
status = await check_liveness("https://example.org/live", client)
assert status == LivenessStatus.LIVE
async def test_liveness_dead_on_error(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="boom")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
status = await check_liveness("https://example.org/dead", client)
assert status == LivenessStatus.DEAD
async def test_liveness_report_dedupes_and_covers() -> None:
report = await liveness_report(["https://a", "https://a", "https://b"])
assert report["https://a"] == LivenessStatus.DEAD
assert len(report) == 2
async def test_liveness_blocked_url_reports_dead() -> None:
status = await check_liveness("http://169.254.169.254/latest/meta-data")
assert status == LivenessStatus.DEAD
def _store_with_report() -> tuple[EvidenceStore, str]:
store = EvidenceStore()
for page in CORPUS[:3]:
store.add_page(page.url, page.title, page.text)
first_passage = store.pages()[0].passage_ids[0]
stored = store.get(first_passage)
sentence = (stored.text if stored else "").split(".")[0] + "."
report = f"# Report\n\n{sentence} [{first_passage}]\n\nMore context [{first_passage}]\n"
return store, report
async def test_ledger_build_and_checklist_export(monkeypatch) -> None:
store, report = _store_with_report()
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="ok")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, follow_redirects=True) as client:
liveness = {}
for page in store.pages():
liveness[page.url] = await check_liveness(page.url, client)
ledger = build_ledger(
run_id="abcd2345",
report_text=report,
evidence=store,
liveness=liveness,
visited_at=datetime.now(UTC),
)
assert ledger.run_id == "abcd2345"
cited_url = store.pages()[0].url
assert [e.url for e in ledger.entries] == [cited_url]
assert ledger.checklist.citations_checked == 1
assert ledger.checklist.live == 1
assert ledger.checklist.dead == 0
assert ledger.checklist.anchored_claims >= 1
assert all(
entry.authority_tier in ("reference", "official", "academic", "general")
for entry in ledger.entries
)
checklist = export_checklist(ledger)
assert "Verification Checklist" in checklist
assert "Live:" in checklist
assert "Weak matches" in checklist
def test_ledger_confidence_labels_single_vs_corroborated() -> None:
store = EvidenceStore()
store.add_passage("https://a", SHARED_SENTENCE)
store.add_passage("https://b", SHARED_SENTENCE)
report = "[p-1] [p-2]"
ledger = build_ledger(
run_id="abcd2345",
report_text=report,
evidence=store,
liveness={"https://a": LivenessStatus.LIVE, "https://b": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
labels = ledger.checklist.confidence_labels
assert len(labels) == 2
corroborated = [v for v in labels.values() if v == "corroborated"]
assert len(corroborated) >= 1
-630
View File
@@ -1,630 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "ast-serialize"
version = "0.11.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" },
{ url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" },
{ url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" },
{ url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" },
{ url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" },
{ url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" },
{ url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" },
{ url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" },
{ url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" },
{ url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" },
{ url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" },
{ url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" },
{ url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" },
{ url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" },
{ url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" },
{ url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" },
{ url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" },
{ url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" },
{ url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" },
{ url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" },
{ url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" },
{ url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" },
{ url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" },
{ url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" },
{ url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" },
{ url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" },
{ url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" },
{ url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" },
{ url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" },
{ url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" },
{ url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" },
{ url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" },
{ url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" },
{ url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" },
{ url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" },
{ url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" },
{ url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.115.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "librt"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" },
{ url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" },
{ url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" },
{ url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" },
{ url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" },
{ url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" },
{ url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" },
{ url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" },
{ url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" },
{ url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" },
{ url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" },
{ url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" },
{ url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" },
{ url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" },
{ url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" },
{ url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" },
{ url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" },
{ url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" },
{ url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" },
{ url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" },
{ url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" },
{ url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" },
{ url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" },
{ url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" },
{ url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" },
{ url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" },
{ url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" },
{ url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" },
{ url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" },
{ url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" },
{ url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" },
{ url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" },
{ url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" },
{ url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" },
{ url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" },
{ url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" },
{ url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" },
{ url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" },
{ url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" },
{ url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" },
{ url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" },
{ url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" },
{ url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" },
{ url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" },
{ url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" },
{ url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" },
{ url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" },
{ url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" },
{ url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" },
{ url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" },
{ url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" },
{ url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" },
{ url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" },
{ url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" },
{ url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" },
{ url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" },
{ url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" },
{ url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" },
{ url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" },
{ url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" },
{ url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" },
{ url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" },
{ url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" },
{ url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" },
{ url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" },
]
[[package]]
name = "mypy"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" },
{ url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" },
{ url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" },
{ url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" },
{ url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" },
{ url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" },
{ url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" },
{ url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" },
{ url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" },
{ url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" },
{ url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
{ url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
{ url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
{ url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
{ url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" },
{ url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" },
{ url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
{ url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
{ url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" },
{ url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" },
{ url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" },
{ url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" },
{ url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" },
{ url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" },
{ url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" },
{ url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" },
{ url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" },
{ url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" },
{ url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "researchx"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "sse-starlette" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115,<0.116" },
{ name = "httpx", specifier = ">=0.27,<1" },
{ name = "pydantic", specifier = ">=2.8,<3" },
{ name = "sse-starlette", specifier = ">=2.1,<3" },
{ name = "uvicorn", specifier = ">=0.30,<1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=1.11" },
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-asyncio", specifier = ">=0.23" },
{ name = "ruff", specifier = ">=0.6" },
]
[[package]]
name = "ruff"
version = "0.16.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
]
[[package]]
name = "sse-starlette"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/3e/eae74d8d33e3262bae0a7e023bb43d8bdd27980aa3557333f4632611151f/sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926", size = 18635, upload-time = "2025-07-06T09:41:33.631Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/f1/6c7eaa8187ba789a6dd6d74430307478d2a91c23a5452ab339b6fbe15a08/sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a", size = 10824, upload-time = "2025-07-06T09:41:32.321Z" },
]
[[package]]
name = "starlette"
version = "0.46.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
-7
View File
@@ -1,12 +1,5 @@
# ResearchX Frontend
**TL;DR** — This is the ResearchX web interface: ask a question, choose a research method, approve the plan, watch the live trace, read the cited report. The prototype runs entirely on scripted sample data — no API keys, no backend, no network — so the full product journey is tryable today.
- **What** — A Next.js app (ask, choose, run, report, library, settings) rendering mock data through one typed client interface.
- **Why** — The journey is checkable end-to-end before live inference exists.
- **Action** — no action — FYI. Developers: Quickstart below (Node 20+, pnpm).
- **Details** — Root `README.md`: backend engine, live mode, architecture.
The prototype frontend for ResearchX — methodology-driven deep research
with a trust loop. This milestone (v0.1) runs **entirely on mock data**:
no API keys, no env vars, no network calls beyond static assets.
-652
View File
@@ -1,652 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "ResearchX API",
"version": "0.2.0"
},
"paths": {
"/api/config": {
"get": {
"summary": "Config Status",
"operationId": "config_status_api_config_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigStatus"
}
}
}
}
}
}
},
"/api/methodologies": {
"get": {
"summary": "Methodologies",
"operationId": "methodologies_api_methodologies_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/MethodologyCard"
},
"type": "array",
"title": "Response Methodologies Api Methodologies Get"
}
}
}
}
}
}
},
"/api/runs": {
"post": {
"summary": "Create Run",
"operationId": "create_run_api_runs_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateRunBody"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RunSummary"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/runs/{run_id}": {
"get": {
"summary": "Get Run",
"operationId": "get_run_api_runs__run_id__get",
"parameters": [
{
"name": "run_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Run Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RunSummary"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/runs/{run_id}/approve": {
"post": {
"summary": "Approve Run",
"operationId": "approve_run_api_runs__run_id__approve_post",
"parameters": [
{
"name": "run_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Run Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/ApproveBody"
},
{
"type": "null"
}
],
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RunSummary"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/runs/{run_id}/pause": {
"post": {
"summary": "Pause Run",
"operationId": "pause_run_api_runs__run_id__pause_post",
"parameters": [
{
"name": "run_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Run Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RunSummary"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/runs/{run_id}/resume": {
"post": {
"summary": "Resume Run",
"operationId": "resume_run_api_runs__run_id__resume_post",
"parameters": [
{
"name": "run_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Run Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RunSummary"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/runs/{run_id}/cancel": {
"post": {
"summary": "Cancel Run",
"operationId": "cancel_run_api_runs__run_id__cancel_post",
"parameters": [
{
"name": "run_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Run Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RunSummary"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/runs/{run_id}/events": {
"get": {
"summary": "Run Events",
"operationId": "run_events_api_runs__run_id__events_get",
"parameters": [
{
"name": "run_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Run Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/runs/{run_id}/report": {
"get": {
"summary": "Run Report",
"operationId": "run_report_api_runs__run_id__report_get",
"parameters": [
{
"name": "run_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Run Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"title": "Response Run Report Api Runs Run Id Report Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/runs/{run_id}/ledger": {
"get": {
"summary": "Run Ledger",
"operationId": "run_ledger_api_runs__run_id__ledger_get",
"parameters": [
{
"name": "run_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Run Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"title": "Response Run Ledger Api Runs Run Id Ledger Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ApproveBody": {
"properties": {
"plan": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Plan"
}
},
"type": "object",
"title": "ApproveBody"
},
"ConfigStatus": {
"properties": {
"model_adapter": {
"type": "string",
"title": "Model Adapter"
},
"search_adapter": {
"type": "string",
"title": "Search Adapter"
},
"storage_dir": {
"type": "string",
"title": "Storage Dir"
},
"probe": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Probe"
},
"cost_counters": {
"additionalProperties": {
"type": "integer"
},
"type": "object",
"title": "Cost Counters"
}
},
"type": "object",
"required": [
"model_adapter",
"search_adapter",
"storage_dir",
"cost_counters"
],
"title": "ConfigStatus"
},
"CreateRunBody": {
"properties": {
"question": {
"type": "string",
"minLength": 1,
"title": "Question"
},
"context": {
"type": "string",
"title": "Context",
"default": ""
},
"methodology": {
"type": "string",
"title": "Methodology"
},
"dial_overrides": {
"additionalProperties": true,
"type": "object",
"title": "Dial Overrides"
}
},
"type": "object",
"required": [
"question",
"methodology"
],
"title": "CreateRunBody"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"MethodologyCard": {
"properties": {
"slug": {
"type": "string",
"title": "Slug"
},
"name": {
"type": "string",
"title": "Name"
},
"description": {
"type": "string",
"title": "Description"
},
"eta_minutes": {
"type": "integer",
"title": "Eta Minutes"
},
"cost_estimate": {
"type": "string",
"title": "Cost Estimate"
},
"why_recommended": {
"type": "string",
"title": "Why Recommended"
},
"output_shape": {
"type": "string",
"title": "Output Shape"
},
"dials": {
"additionalProperties": true,
"type": "object",
"title": "Dials"
}
},
"type": "object",
"required": [
"slug",
"name",
"description",
"eta_minutes",
"cost_estimate",
"why_recommended",
"output_shape",
"dials"
],
"title": "MethodologyCard"
},
"RunSummary": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"status": {
"type": "string",
"title": "Status"
},
"question": {
"type": "string",
"title": "Question"
},
"methodology": {
"type": "string",
"title": "Methodology"
},
"plan": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Plan"
},
"started_at": {
"type": "string",
"title": "Started At"
}
},
"type": "object",
"required": [
"id",
"status",
"question",
"methodology",
"started_at"
],
"title": "RunSummary"
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
}
}
}
}
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@researchx/frontend",
"version": "0.2.5",
"version": "0.1.8",
"private": true,
"packageManager": "pnpm@12.6.0",
"scripts": {
@@ -30,11 +30,8 @@
"@vitejs/plugin-react": "4.7.0",
"eslint": "^9.39.5",
"eslint-config-next": "15.5.6",
"eventsource": "^5.1.2",
"globals": "^17.0.0",
"jsdom": "26.1.0",
"openapi-fetch": "^0.13.8",
"openapi-typescript": "^7.13.0",
"tailwindcss": "4.1.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.1",
+123 -305
View File
@@ -168,7 +168,7 @@ importers:
dependencies:
next:
specifier: 15.5.6
version: 15.5.6(@babel/core@7.29.7(supports-color@10.2.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
version: 15.5.6(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react:
specifier: 19.2.8
version: 19.2.8
@@ -178,7 +178,7 @@ importers:
devDependencies:
'@eslint/eslintrc':
specifier: ^3.3.7
version: 3.3.7(supports-color@10.2.2)
version: 3.3.7(supports-color@7.2.0)
'@tailwindcss/postcss':
specifier: 4.1.18
version: 4.1.18
@@ -205,28 +205,19 @@ importers:
version: 19.2.3(@types/react@19.2.5)
'@vitejs/plugin-react':
specifier: 4.7.0
version: 4.7.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2))
version: 4.7.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2))
eslint:
specifier: ^9.39.5
version: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-config-next:
specifier: 15.5.6
version: 15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
eventsource:
specifier: ^5.1.2
version: 5.1.2
version: 15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
globals:
specifier: ^17.0.0
version: 17.12.0
jsdom:
specifier: 26.1.0
version: 26.1.0(supports-color@10.2.2)
openapi-fetch:
specifier: ^0.13.8
version: 0.13.8
openapi-typescript:
specifier: ^7.13.0
version: 7.13.0(typescript@5.9.3)
version: 26.1.0(supports-color@7.2.0)
tailwindcss:
specifier: 4.1.18
version: 4.1.18
@@ -235,10 +226,10 @@ importers:
version: 5.9.3
typescript-eslint:
specifier: ^8.70.1
version: 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
version: 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
vitest:
specifier: 3.2.7
version: 3.2.7(@types/node@24.3.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.30.2)(supports-color@10.2.2)
version: 3.2.7(@types/node@24.3.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.30.2)(supports-color@7.2.0)
packages:
@@ -850,16 +841,6 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
'@redocly/config@0.22.0':
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
'@redocly/openapi-core@1.34.20':
resolution: {integrity: sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -1409,10 +1390,6 @@ packages:
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -1505,9 +1482,6 @@ packages:
brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
brace-expansion@2.1.7:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
@@ -1552,9 +1526,6 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -1569,9 +1540,6 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -1858,14 +1826,6 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventsource-parser@4.1.1:
resolution: {integrity: sha512-zaWNqeSLeyzEAAt2Q6aDBRDRk/VnPmsKqvToyGvv6aahvLv71A4Jw7jG93Hckf11zXurbqgsffvpSgKPoeU1Qw==}
engines: {node: '>=22.12'}
eventsource@5.1.2:
resolution: {integrity: sha512-VJnwqqp+fOT3WDkjnUy49Zuyh48+kH0JdJApZkslrRQyx35zIZJeJYJl0+a+LrTzh5TFv6MdmXnBDv71UXFyWQ==}
engines: {node: '>=22.12.0'}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@@ -2046,10 +2006,6 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -2178,10 +2134,6 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2212,9 +2164,6 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -2369,10 +2318,6 @@ packages:
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -2457,18 +2402,6 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
openapi-fetch@0.13.8:
resolution: {integrity: sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==}
openapi-typescript-helpers@0.0.15:
resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==}
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2489,10 +2422,6 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -2525,10 +2454,6 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2590,10 +2515,6 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2763,10 +2684,6 @@ packages:
babel-plugin-macros:
optional: true
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2842,10 +2759,6 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -2890,9 +2803,6 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -3042,13 +2952,6 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -3075,20 +2978,20 @@ snapshots:
'@babel/compat-data@7.29.7': {}
'@babel/core@7.29.7(supports-color@10.2.2)':
'@babel/core@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.9
'@babel/template': 7.29.7
'@babel/traverse': 7.29.8(supports-color@10.2.2)
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -3113,19 +3016,19 @@ snapshots:
'@babel/helper-globals@7.29.7': {}
'@babel/helper-module-imports@7.29.7(supports-color@10.2.2)':
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/traverse': 7.29.8(supports-color@10.2.2)
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)':
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-module-imports': 7.29.7(supports-color@10.2.2)
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.8(supports-color@10.2.2)
'@babel/traverse': 7.29.8(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -3146,14 +3049,14 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
'@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))':
'@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))':
dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))':
'@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))':
dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.29.7
'@babel/runtime@7.29.7': {}
@@ -3164,7 +3067,7 @@ snapshots:
'@babel/parser': 7.29.9
'@babel/types': 7.29.8
'@babel/traverse@7.29.8(supports-color@10.2.2)':
'@babel/traverse@7.29.8(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
@@ -3172,7 +3075,7 @@ snapshots:
'@babel/parser': 7.29.9
'@babel/template': 7.29.7
'@babel/types': 7.29.8
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -3300,17 +3203,17 @@ snapshots:
'@esbuild/win32-x64@0.28.2':
optional: true
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))':
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.21.2(supports-color@10.2.2)':
'@eslint/config-array@0.21.2(supports-color@7.2.0)':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -3323,10 +3226,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/eslintrc@3.3.7(supports-color@10.2.2)':
'@eslint/eslintrc@3.3.7(supports-color@7.2.0)':
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -3532,29 +3435,6 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js-replace: 1.0.1
'@redocly/config@0.22.0': {}
'@redocly/openapi-core@1.34.20(supports-color@10.2.2)':
dependencies:
'@redocly/ajv': 8.11.2
'@redocly/config': 0.22.0
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
js-levenshtein: 1.1.6
js-yaml: 4.3.2
minimatch: 5.1.9
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- supports-color
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.63.5':
@@ -3797,15 +3677,15 @@ snapshots:
dependencies:
csstype: 3.2.3
'@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.70.1
'@typescript-eslint/type-utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/type-utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.70.1
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
ignore: 7.0.10
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -3813,23 +3693,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.70.1
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.70.1
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.70.1(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/project-service@8.70.1(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3)
'@typescript-eslint/types': 8.70.1
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -3843,13 +3723,13 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/type-utils@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -3857,13 +3737,13 @@ snapshots:
'@typescript-eslint/types@8.70.1': {}
'@typescript-eslint/typescript-estree@8.70.1(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/typescript-estree@8.70.1(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.70.1(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/project-service': 8.70.1(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3)
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/visitor-keys': 8.70.1
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
@@ -3872,13 +3752,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/utils@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
'@typescript-eslint/scope-manager': 8.70.1
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -3958,11 +3838,11 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vitejs/plugin-react@4.7.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2))':
'@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2))':
dependencies:
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
'@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
'@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
@@ -4027,8 +3907,6 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-colors@4.1.3: {}
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
@@ -4137,10 +4015,6 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.1.7:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -4193,8 +4067,6 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
change-case@5.4.4: {}
check-error@2.1.3: {}
client-only@0.0.1: {}
@@ -4205,8 +4077,6 @@ snapshots:
color-name@1.1.4: {}
colorette@1.4.0: {}
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
@@ -4251,17 +4121,17 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
debug@3.2.7(supports-color@10.2.2):
debug@3.2.7(supports-color@7.2.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
supports-color: 7.2.0
debug@4.4.3(supports-color@10.2.2):
debug@4.4.3(supports-color@7.2.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
supports-color: 7.2.0
decimal.js@10.6.0: {}
@@ -4456,19 +4326,19 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-next@15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3):
eslint-config-next@15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.5.6
'@rushstack/eslint-patch': 1.16.1
'@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint-import-resolver-node: 0.3.10(supports-color@10.2.2)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))
eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))
'@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -4476,52 +4346,52 @@ snapshots:
- eslint-plugin-import-x
- supports-color
eslint-import-resolver-node@0.3.10(supports-color@10.2.2):
eslint-import-resolver-node@0.3.10(supports-color@7.2.0):
dependencies:
debug: 3.2.7(supports-color@10.2.2)
debug: 3.2.7(supports-color@7.2.0)
is-core-module: 2.17.0
resolve: 2.0.0-next.7
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
get-tsconfig: 4.14.3
is-bun-module: 2.0.0
stable-hash: 0.0.5
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2):
eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
debug: 3.2.7(supports-color@10.2.2)
debug: 3.2.7(supports-color@7.2.0)
optionalDependencies:
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint-import-resolver-node: 0.3.10(supports-color@10.2.2)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.2.0
array.prototype.findlastindex: 1.2.6
array.prototype.flat: 1.3.3
array.prototype.flatmap: 1.3.3
debug: 3.2.7(supports-color@10.2.2)
debug: 3.2.7(supports-color@7.2.0)
doctrine: 2.1.0
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint-import-resolver-node: 0.3.10(supports-color@10.2.2)
eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
hasown: 2.0.4
is-core-module: 2.17.0
is-glob: 4.0.3
@@ -4533,13 +4403,13 @@ snapshots:
string.prototype.trimend: 1.0.10
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)):
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)):
dependencies:
aria-query: 5.3.2
array-includes: 3.2.0
@@ -4549,7 +4419,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
hasown: 2.0.4
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -4558,11 +4428,11 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
eslint-plugin-react-hooks@5.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)):
eslint-plugin-react-hooks@5.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)):
dependencies:
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)):
eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)):
dependencies:
array-includes: 3.2.0
array.prototype.findlast: 1.2.5
@@ -4570,7 +4440,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.4.0
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
estraverse: 5.3.0
hasown: 2.0.4
jsx-ast-utils: 3.3.5
@@ -4595,14 +4465,14 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2):
eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0):
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2(supports-color@10.2.2)
'@eslint/config-array': 0.21.2(supports-color@7.2.0)
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.7(supports-color@10.2.2)
'@eslint/eslintrc': 3.3.7(supports-color@7.2.0)
'@eslint/js': 9.39.5
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
@@ -4612,7 +4482,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -4658,12 +4528,6 @@ snapshots:
esutils@2.0.3: {}
eventsource-parser@4.1.1: {}
eventsource@5.1.2:
dependencies:
eventsource-parser: 4.1.1
expect-type@1.4.0: {}
fast-deep-equal@3.1.3: {}
@@ -4810,17 +4674,17 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
http-proxy-agent@7.0.2(supports-color@10.2.2):
http-proxy-agent@7.0.2(supports-color@7.2.0):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6(supports-color@10.2.2):
https-proxy-agent@7.0.6(supports-color@7.2.0):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -4841,8 +4705,6 @@ snapshots:
indent-string@4.0.0: {}
index-to-position@1.2.0: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4982,8 +4844,6 @@ snapshots:
jiti@2.7.0: {}
js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -4992,14 +4852,14 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@26.1.0(supports-color@10.2.2):
jsdom@26.1.0(supports-color@7.2.0):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2(supports-color@10.2.2)
https-proxy-agent: 7.0.6(supports-color@10.2.2)
http-proxy-agent: 7.0.2(supports-color@7.2.0)
https-proxy-agent: 7.0.6(supports-color@7.2.0)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28
parse5: 7.3.0
@@ -5025,8 +4885,6 @@ snapshots:
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@@ -5149,10 +5007,6 @@ snapshots:
dependencies:
brace-expansion: 1.1.21
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.7
minimist@1.2.8: {}
ms@2.1.3: {}
@@ -5163,7 +5017,7 @@ snapshots:
natural-compare@1.4.0: {}
next@15.5.6(@babel/core@7.29.7(supports-color@10.2.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
next@15.5.6(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@next/env': 15.5.6
'@swc/helpers': 0.5.15
@@ -5171,7 +5025,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.8)
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.8)
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.6
'@next/swc-darwin-x64': 15.5.6
@@ -5239,22 +5093,6 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.2
openapi-fetch@0.13.8:
dependencies:
openapi-typescript-helpers: 0.0.15
openapi-typescript-helpers@0.0.15: {}
openapi-typescript@7.13.0(typescript@5.9.3):
dependencies:
'@redocly/openapi-core': 1.34.20(supports-color@10.2.2)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.3.0
supports-color: 10.2.2
typescript: 5.9.3
yargs-parser: 21.1.1
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -5283,12 +5121,6 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.29.7
index-to-position: 1.2.0
type-fest: 4.41.0
parse5@7.3.0:
dependencies:
entities: 6.0.1
@@ -5309,8 +5141,6 @@ snapshots:
picomatch@4.0.7: {}
pluralize@8.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
@@ -5381,8 +5211,6 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
require-from-string@2.0.2: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -5633,14 +5461,12 @@ snapshots:
dependencies:
js-tokens: 9.0.1
styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.8):
styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.8):
dependencies:
client-only: 0.0.1
react: 19.2.8
optionalDependencies:
'@babel/core': 7.29.7(supports-color@10.2.2)
supports-color@10.2.2: {}
'@babel/core': 7.29.7(supports-color@7.2.0)
supports-color@7.2.0:
dependencies:
@@ -5704,8 +5530,6 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@4.41.0: {}
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -5738,13 +5562,13 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typescript-eslint@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3):
typescript-eslint@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
'@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -5793,16 +5617,14 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uri-js-replace@1.0.1: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
vite-node@3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@10.2.2):
vite-node@3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@7.2.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)
@@ -5834,7 +5656,7 @@ snapshots:
jiti: 2.7.0
lightningcss: 1.30.2
vitest@3.2.7(@types/node@24.3.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.30.2)(supports-color@10.2.2):
vitest@3.2.7(@types/node@24.3.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.30.2)(supports-color@7.2.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
@@ -5845,7 +5667,7 @@ snapshots:
'@vitest/spy': 3.2.7
'@vitest/utils': 3.2.7
chai: 5.3.3
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@7.2.0)
expect-type: 1.4.0
magic-string: 0.30.21
pathe: 2.0.3
@@ -5857,11 +5679,11 @@ snapshots:
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)
vite-node: 3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@10.2.2)
vite-node: 3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@7.2.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.3.1
jsdom: 26.1.0(supports-color@10.2.2)
jsdom: 26.1.0(supports-color@7.2.0)
transitivePeerDependencies:
- jiti
- less
@@ -5953,8 +5775,4 @@ snapshots:
yallist@3.1.1: {}
yaml-ast-parser@0.0.43: {}
yargs-parser@21.1.1: {}
yocto-queue@0.1.0: {}
@@ -107,6 +107,6 @@ describe("SettingsView (REQ-65)", () => {
expect(screen.getByText("preview", { exact: true })).toBeInTheDocument();
expect(screen.getByText(/About & version|About and version/i)).toBeInTheDocument();
expect(screen.getByText(/v0\.2\.5 · MVP release candidate/i)).toBeInTheDocument();
expect(screen.getByText(/v0\.1\.8 · prototype/i)).toBeInTheDocument();
});
});
+2 -3
View File
@@ -244,9 +244,8 @@ export function SettingsView() {
visited-only citations, three clicks to a trustworthy report.
</p>
<p className="mt-2 text-caption text-ink-faint">
v0.2.5 · MVP release candidate — mock-backed by default; the
engine, adapters, and trust loop ship with v0.2 (live mode:
NEXT_PUBLIC_RESEARCHX_API_MODE=live).
v0.1.8 · prototype — runs on scripted mock data; the engine,
adapters, and trust loop arrive in v0.2.
</p>
</section>
+4 -40
View File
@@ -26,7 +26,6 @@ import type {
UserProfile,
} from "@/lib/types";
import { MockApiClient } from "@/lib/api/mock/mock-client";
import { GeneratedApiClient } from "@/lib/api/live-client";
export type { Report, ReportSection } from "@/lib/types";
export type {
@@ -67,8 +66,7 @@ export interface CreateRunInput {
/**
* Subscription handle for the live trace stream (EventSource-shaped).
* v0.2: backed by SSE in live mode; client-side pause/resume kept
* (server-side pause/resume added via the P3 extension below).
* v0.2 backs this with SSE; pause/resume stay client-side concerns.
*/
export interface RunEventSubscription {
/** Stop the stream and release its timers (idempotent). */
@@ -79,18 +77,6 @@ export interface RunEventSubscription {
resume(): void;
}
/**
* v0.2 P3 extension (grill G-003 — the single planned interface
* addition): server-side pause/resume of a running engine across
* restarts. Mock mode ignores it; live mode hits the backend.
*/
export interface RunLifecycle {
/** Pause the engine server-side (durable across restarts). */
pauseRun(id: string): Promise<ResearchRun>;
/** Resume a server-side-paused run. */
resumeRun(id: string): Promise<ResearchRun>;
}
/**
* The backend-swappable contract. Every screen talks to this — never
* to a concrete client, never to the database, never to the network.
@@ -195,29 +181,7 @@ export interface ApiClient {
/**
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
* event replay, zero API keys). v0.2 P3 (REQ-26/REQ-54): the binding
* switches to the OpenAPI-generated client in live mode — selected by
* NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Live mode covers
* the core research loop only (G-002); other surfaces stay mock-backed.
* event replay, zero API keys). v0.2 P3: rebind to the OpenAPI-generated
* client — this line is the only thing that changes.
*/
class MockApiClientWithLifecycle extends MockApiClient implements RunLifecycle {
async pauseRun(id: string): Promise<ResearchRun> {
return this.getRun(id);
}
async resumeRun(id: string): Promise<ResearchRun> {
return this.getRun(id);
}
}
function resolveBinding(): ApiClient & RunLifecycle {
const mode =
process.env.NEXT_PUBLIC_RESEARCHX_API_MODE ??
process.env.RESEARCHX_API_MODE ??
"mock";
if (mode === "live") {
return new GeneratedApiClient() as unknown as ApiClient & RunLifecycle;
}
return new MockApiClientWithLifecycle();
}
export const api: ApiClient & RunLifecycle = resolveBinding();
export const api: ApiClient = new MockApiClient();
-215
View File
@@ -1,215 +0,0 @@
/**
* @vitest-environment node
*
* E2E happy path on the REAL stack (REQ-17): new user → research
* running in ≤3 clicks using defaults only.
*
* The live ApiClient binding talks to a real FastAPI app (engine +
* fakes) through httpx's ASGI transport wired in as fetch. This is
* the full boundary → API → engine → trust-loop loop — no mocks in
* the data path. Click budget: submit question (1), pick methodology
* (2), approve plan (3).
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { EventSource as EventSourcePolyfill } from "eventsource";
const g = globalThis as unknown as { EventSource?: unknown };
if (!g.EventSource) {
g.EventSource = EventSourcePolyfill;
}
import { GeneratedApiClient } from "@/lib/api/live-client";
type Lifecycle = { pauseRun(id: string): Promise<unknown> };
let client: GeneratedApiClient & Lifecycle;
let server: {
ready: Promise<void>;
close: () => Promise<void>;
app: unknown;
};
beforeAll(async () => {
const { spawn } = await import("node:child_process");
const { resolve } = await import("node:path");
const backendDir = resolve(process.cwd(), "..", "backend");
const child = spawn(
"/bin/bash",
["-lc", "uv run python -m researchx.api.dev_bridge"],
{
cwd: backendDir,
stdio: ["pipe", "pipe", "inherit"],
},
);
const ready = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("bridge not ready in 60s")), 60_000);
let buf = "";
child.stdout!.on("data", (chunk: Buffer) => {
buf += chunk.toString();
if (buf.includes("READY")) {
clearTimeout(timer);
resolve();
}
});
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
server = {
app: child,
ready,
close: async () => {
child.kill("SIGTERM");
},
};
await ready;
const waitForPort = async (): Promise<void> => {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const probe = await fetch("http://127.0.0.1:8000/api/config");
if (probe.ok) {
return;
}
} catch {
// not listening yet
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error("backend bridge never listened on 8000");
};
await waitForPort();
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const req = input instanceof Request ? input : new Request(input, init);
const body = req.body ? await new Response(req.body).text() : undefined;
const headers: Record<string, string> = {};
req.headers.forEach((v, k) => {
headers[k] = v;
});
const response = await fetch("http://127.0.0.1:8000" + new URL(req.url).pathname, {
method: req.method,
headers,
...(body ? { body } : {}),
});
return response;
}) as typeof globalThis.fetch;
client = new GeneratedApiClient(fetchImpl) as GeneratedApiClient & Lifecycle;
}, 90_000);
afterAll(async () => {
await server?.close?.();
});
describe("E2E happy path — real stack, defaults only (REQ-17)", () => {
it("runs research in ≤3 user actions", async () => {
// Action budget: 1) submit question on Ask, 2) pick methodology on
// Choose (defaults preselected — the plan requires zero dial fiddling),
// 3) approve plan on Plan & Run.
let userActions = 0;
const methodologies = await client.getMethodologies(); // catalog load — not a click
expect(methodologies.length).toBeGreaterThanOrEqual(2);
const question = "How do verification pipelines keep citations honest?";
userActions += 1; // Ask: submit question
const run = await client.createRun({
question,
methodologySlug: "rapid-answer",
});
expect(run.status).toBeOneOf(["planning", "awaiting_approval"]);
userActions += 2; // Choose: pick methodology + Plan & Run: approve
let current = await client.getRun(run.id);
const deadline = Date.now() + 30_000;
while (current.status === "planning" && Date.now() < deadline) {
current = await client.getRun(run.id);
}
expect(current.plan.subQuestions.length).toBeGreaterThanOrEqual(1);
const approved = await client.approvePlan(run.id);
expect(approved.status).not.toBe("planning");
let done = false;
const events: string[] = [];
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("run timed out")), 60_000);
const subscription = client.getRunEvents(
run.id,
(event) => {
events.push(event.type);
},
() => {
done = true;
clearTimeout(timer);
resolve();
},
);
// Safety valve — cancel the subscription if the run hangs.
setTimeout(() => {
if (!done) {
subscription.cancel();
clearTimeout(timer);
reject(new Error("no run_done within window"));
}
}, 55_000);
});
expect(done).toBe(true);
expect(events).toContain("status");
const final = await client.getRun(run.id);
expect(final.status).toBe("done");
expect(userActions).toBeLessThanOrEqual(3);
}, 90_000);
it("exports round-trip: markdown carries citation links, JSON validates (REQ-34)", async () => {
const run = await client.createRun({
question: "Export round-trip proof",
methodologySlug: "rapid-answer",
});
const deadline = Date.now() + 30_000;
let current = await client.getRun(run.id);
while (current.status === "planning" && Date.now() < deadline) {
current = await client.getRun(run.id);
}
await client.approvePlan(run.id);
const doneDeadline = Date.now() + 60_000;
while (current.status !== "done" && Date.now() < doneDeadline) {
current = await client.getRun(run.id);
}
expect(current.status).toBe("done");
const markdown = await client.exportReport(run.id, "markdown");
expect(markdown.filename.endsWith(".md")).toBe(true);
expect(markdown.content).toContain("[p-");
const linkRe = /\[https?:\/\/[^\]]+\]\(https?:\/\/[^)]+\)/;
expect(markdown.content).toMatch(linkRe);
const json = await client.exportReport(run.id, "json");
expect(json.filename.endsWith(".json")).toBe(true);
const parsed = JSON.parse(json.content) as {
run_id: string;
entries: Array<{
url: string;
visited_at: string;
passages: string[];
health: string;
authority_tier: string | number;
}>;
};
expect(parsed.run_id).toBe(run.id);
expect(parsed.entries.length).toBeGreaterThanOrEqual(1);
for (const entry of parsed.entries) {
expect(entry.url).toMatch(/^https?:\/\//);
expect(entry.health).toBeOneOf(["live", "stale", "dead"]);
expect(Array.isArray(entry.passages)).toBe(true);
}
}, 90_000);
});
-628
View File
@@ -1,628 +0,0 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Config Status */
get: operations["config_status_api_config_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/methodologies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Methodologies */
get: operations["methodologies_api_methodologies_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/runs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Create Run */
post: operations["create_run_api_runs_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/runs/{run_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Run */
get: operations["get_run_api_runs__run_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/runs/{run_id}/approve": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Approve Run */
post: operations["approve_run_api_runs__run_id__approve_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/runs/{run_id}/pause": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Pause Run */
post: operations["pause_run_api_runs__run_id__pause_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/runs/{run_id}/resume": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Resume Run */
post: operations["resume_run_api_runs__run_id__resume_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/runs/{run_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Cancel Run */
post: operations["cancel_run_api_runs__run_id__cancel_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/runs/{run_id}/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Run Events */
get: operations["run_events_api_runs__run_id__events_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/runs/{run_id}/report": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Run Report */
get: operations["run_report_api_runs__run_id__report_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/runs/{run_id}/ledger": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Run Ledger */
get: operations["run_ledger_api_runs__run_id__ledger_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** ApproveBody */
ApproveBody: {
/** Plan */
plan?: {
[key: string]: unknown;
} | null;
};
/** ConfigStatus */
ConfigStatus: {
/** Model Adapter */
model_adapter: string;
/** Search Adapter */
search_adapter: string;
/** Storage Dir */
storage_dir: string;
/** Probe */
probe?: {
[key: string]: unknown;
} | null;
/** Cost Counters */
cost_counters: {
[key: string]: number;
};
};
/** CreateRunBody */
CreateRunBody: {
/** Question */
question: string;
/**
* Context
* @default
*/
context: string;
/** Methodology */
methodology: string;
/** Dial Overrides */
dial_overrides?: {
[key: string]: unknown;
};
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** MethodologyCard */
MethodologyCard: {
/** Slug */
slug: string;
/** Name */
name: string;
/** Description */
description: string;
/** Eta Minutes */
eta_minutes: number;
/** Cost Estimate */
cost_estimate: string;
/** Why Recommended */
why_recommended: string;
/** Output Shape */
output_shape: string;
/** Dials */
dials: {
[key: string]: unknown;
};
};
/** RunSummary */
RunSummary: {
/** Id */
id: string;
/** Status */
status: string;
/** Question */
question: string;
/** Methodology */
methodology: string;
/** Plan */
plan?: {
[key: string]: unknown;
} | null;
/** Started At */
started_at: string;
};
/** ValidationError */
ValidationError: {
/** Location */
loc: (string | number)[];
/** Message */
msg: string;
/** Error Type */
type: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
config_status_api_config_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ConfigStatus"];
};
};
};
};
methodologies_api_methodologies_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MethodologyCard"][];
};
};
};
};
create_run_api_runs_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateRunBody"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RunSummary"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_run_api_runs__run_id__get: {
parameters: {
query?: never;
header?: never;
path: {
run_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RunSummary"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
approve_run_api_runs__run_id__approve_post: {
parameters: {
query?: never;
header?: never;
path: {
run_id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["ApproveBody"] | null;
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RunSummary"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
pause_run_api_runs__run_id__pause_post: {
parameters: {
query?: never;
header?: never;
path: {
run_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RunSummary"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
resume_run_api_runs__run_id__resume_post: {
parameters: {
query?: never;
header?: never;
path: {
run_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RunSummary"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
cancel_run_api_runs__run_id__cancel_post: {
parameters: {
query?: never;
header?: never;
path: {
run_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RunSummary"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
run_events_api_runs__run_id__events_get: {
parameters: {
query?: never;
header?: never;
path: {
run_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
run_report_api_runs__run_id__report_get: {
parameters: {
query?: never;
header?: never;
path: {
run_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
run_ledger_api_runs__run_id__ledger_get: {
parameters: {
query?: never;
header?: never;
path: {
run_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}
-157
View File
@@ -1,157 +0,0 @@
/**
* Boundary tests for the v0.2 P3 client swap (REQ-26, G-003).
*
* Live mode is contract-tested against a stubbed global fetch — no
* network, no keys. Mock mode stays pinned by the existing suite.
*/
import { describe, expect, it } from "vitest";
import { GeneratedApiClient } from "@/lib/api/live-client";
import type { BackendRun } from "@/lib/api/test-helpers";
type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown };
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
for (const [prefix, handler] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
const { status, body } = handler(url, init);
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
}) as typeof globalThis.fetch;
}
const backendRun: BackendRun = {
id: "abcd2345",
status: "awaiting_approval",
question: "How do engines run?",
methodology: "rapid-answer",
plan: {
sub_questions: ["verification passes"],
source_universe: ["web"],
outline: ["intro"],
},
started_at: "2026-09-27T12:00:00Z",
};
describe("GeneratedApiClient (live binding)", () => {
it("maps backend methodology cards to the frontend Methodology shape", async () => {
const routes = {
"http://127.0.0.1:8000/api/methodologies": (): { status: number; body: unknown } => ({
status: 200,
body: [
{
slug: "rapid-answer",
name: "Rapid Answer",
description: "One pass, one memo. Fastest path.",
eta_minutes: 2,
cost_estimate: "~5k tokens (estimate)",
why_recommended: "Fastest path to a confident cited answer.",
output_shape: "memo",
dials: { depth: 1, breadth: 1 },
},
],
}),
};
const client = new GeneratedApiClient(stubRoutes(routes));
const cards = await client.getMethodologies();
expect(cards).toHaveLength(1);
expect(cards[0]?.slug).toBe("rapid-answer");
expect(cards[0]?.etaMinutes).toBe(2);
expect(cards[0]?.costEstimate).toContain("estimate");
expect(cards[0]?.defaultDials).toEqual({ depth: 1, breadth: 1 });
});
it("creates runs through the backend contract", async () => {
const routes = {
"http://127.0.0.1:8000/api/runs": (url: string, init?: RequestInit) => {
const method =
init?.method ?? (url.endsWith("/api/runs") ? "POST" : undefined);
return {
status: 201,
body: method === "POST" ? backendRun : { detail: "wrong method" },
};
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
const run = await client.createRun({
question: "How do engines run?",
methodologySlug: "rapid-answer",
});
expect(run.id).toBe("abcd2345");
expect(run.status).toBe("awaiting_approval");
expect(run.plan.subQuestions).toEqual(["verification passes"]);
expect(run.plan.methodologySlug).toBe("rapid-answer");
});
it("approves, cancels, pauses, resumes via lifecycle routes", async () => {
const routes = {
"http://127.0.0.1:8000/api/runs/abcd2345/approve": () => ({
status: 200,
body: { ...backendRun, status: "searching" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/cancel": () => ({
status: 200,
body: { ...backendRun, status: "cancelled" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/pause": () => ({
status: 200,
body: { ...backendRun, status: "searching" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/resume": () => ({
status: 200,
body: { ...backendRun, status: "verifying" },
}),
};
const client = new GeneratedApiClient(stubRoutes(routes));
expect((await client.approvePlan("abcd2345")).status).toBe("searching");
expect((await client.cancelRun("abcd2345")).status).toBe("cancelled");
expect((await client.pauseRun("abcd2345")).status).toBe("searching");
expect((await client.resumeRun("abcd2345")).status).toBe("verifying");
});
it("maps the ledger to frontend citations with health + tier", async () => {
const routes = {
"http://127.0.0.1:8000/api/runs/abcd2345/ledger": () => ({
status: 200,
body: {
run_id: "abcd2345",
generated_at: "2026-09-27T12:01:00Z",
entries: [
{
url: "https://example.org/a",
visited_at: "2026-09-27T12:00:00Z",
passages: ["p-1", "p-2"],
claims_supported: 2,
health: "live",
authority_tier: "official",
},
],
},
}),
};
const client = new GeneratedApiClient(stubRoutes(routes));
const ledger = await client.getLedger("abcd2345");
expect(ledger.runId).toBe("abcd2345");
expect(ledger.citations[0]?.verification).toBe("live");
expect(ledger.citations[0]?.authorityTier).toBe(1);
});
it("fetches the report markdown", async () => {
const routes = {
"http://127.0.0.1:8000/api/runs/abcd2345/report": () => ({
status: 200,
body: { run_id: "abcd2345", markdown: "# Report\n\nClaim [p-1]." },
}),
};
const client = new GeneratedApiClient(stubRoutes(routes));
const report = await client.getReport("abcd2345");
expect(report.sections[0]?.body).toContain("[p-1]");
});
});
-390
View File
@@ -1,390 +0,0 @@
/**
* GeneratedApiClient — the live-mode ApiClient binding (v0.2 P3, REQ-26/REQ-54).
*
* Core research loop methods (methodologies, runs, events, report,
* ledger, export) hit the FastAPI backend through openapi-fetch,
* typed by the generated schema. Per G-002, the remaining prototype
* surfaces (library, settings, suggestions, refine, graph, inbox,
* profile) stay mock-backed until v0.3+ — this class delegates those
* to the MockApiClient rather than reimplementing them.
*
* Components never import this class — only client.ts (the boundary)
* does, selected by RESEARCHX_API_MODE=live (mock is the default).
*/
import createClient from "openapi-fetch";
import type { paths } from "@/lib/api/generated/schema";
import type {
CitationLedger,
Methodology,
Report,
ResearchRun,
TraceEvent,
} from "@/lib/types";
import type {
CreateRunInput,
RunEventSubscription,
} from "@/lib/api/client";
import { MockApiClient } from "@/lib/api/mock/mock-client";
function baseUrl(): string {
return (
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000"
);
}
type BackendRun = {
id: string;
status: string;
question: string;
methodology: string;
plan: {
sub_questions: string[];
source_universe: string[];
outline: string[];
} | null;
started_at: string;
};
type BackendCard = {
slug: string;
name: string;
description: string;
eta_minutes: number;
cost_estimate: string;
why_recommended: string;
output_shape: string;
dials: Record<string, unknown>;
};
export class GeneratedApiClient extends MockApiClient {
#client: ReturnType<typeof createClient<paths>>;
constructor(fetchImpl?: typeof globalThis.fetch) {
super();
this.#client = createClient<paths>({
baseUrl: baseUrl(),
...(fetchImpl ? { fetch: fetchImpl } : {}),
});
}
override async getMethodologies(): Promise<Methodology[]> {
const { data, error } = await this.#client.GET("/api/methodologies");
if (error || !data) {
throw new Error("live: failed to load methodologies");
}
return (data as BackendCard[]).map((card, index) => ({
slug: card.slug,
name: card.name,
tagline: card.description.split(".")[0] ?? card.description,
description: card.description,
etaMinutes: card.eta_minutes,
costEstimate: card.cost_estimate,
whyRecommended: card.why_recommended,
recommended: index === 0,
verification: card.slug === "deep-report" ? "full" : "standard",
dialSummary: Object.entries(card.dials).map(
([dial, value]) => `${dial}: ${String(value)}`,
),
defaultDials: card.dials as unknown as Methodology["defaultDials"],
badge: card.slug === "rapid-answer" ? "Fastest" : undefined,
}));
}
#toRun(run: BackendRun): ResearchRun {
const plan = run.plan;
return {
id: run.id,
status: run.status as ResearchRun["status"],
plan: {
id: run.id,
question: run.question,
methodologySlug: run.methodology,
subQuestions: plan?.sub_questions ?? [],
sourceUniverse: plan?.source_universe ?? [],
outline: plan?.outline ?? [],
},
startedAt: run.started_at,
};
}
override async createRun(input: CreateRunInput): Promise<ResearchRun> {
const overrides: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input.dialOverrides ?? {})) {
const snake = key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
overrides[snake] = value;
}
const { data, error } = await this.#client.POST("/api/runs", {
body: {
question: input.question,
context: input.context ?? "",
methodology: input.methodologySlug,
dial_overrides: overrides,
},
});
if (error || !data) {
throw new Error("live: failed to create run");
}
return this.#toRun(data as BackendRun);
}
override async getRun(id: string): Promise<ResearchRun> {
const { data, error } = await this.#client.GET("/api/runs/{run_id}", {
params: { path: { run_id: id } },
});
if (error || !data) {
throw new Error(`live: failed to load run ${id}`);
}
return this.#toRun(data as BackendRun);
}
override async approvePlan(id: string): Promise<ResearchRun> {
const { data, error } = await this.#client.POST(
"/api/runs/{run_id}/approve",
{ params: { path: { run_id: id } }, body: {} },
);
if (error || !data) {
throw new Error(`live: failed to approve run ${id}`);
}
return this.#toRun(data as BackendRun);
}
override async cancelRun(id: string): Promise<ResearchRun> {
const { data, error } = await this.#client.POST(
"/api/runs/{run_id}/cancel",
{ params: { path: { run_id: id } } },
);
if (error || !data) {
throw new Error(`live: failed to cancel run ${id}`);
}
return this.#toRun(data as BackendRun);
}
async pauseRun(id: string): Promise<ResearchRun> {
const { data, error } = await this.#client.POST(
"/api/runs/{run_id}/pause",
{ params: { path: { run_id: id } } },
);
if (error || !data) {
throw new Error(`live: failed to pause run ${id}`);
}
return this.#toRun(data as BackendRun);
}
async resumeRun(id: string): Promise<ResearchRun> {
const { data, error } = await this.#client.POST(
"/api/runs/{run_id}/resume",
{ params: { path: { run_id: id } } },
);
if (error || !data) {
throw new Error(`live: failed to resume run ${id}`);
}
return this.#toRun(data as BackendRun);
}
override getRunEvents(
id: string,
onEvent: (event: TraceEvent) => void,
onDone?: () => void,
): RunEventSubscription {
const source = new EventSource(
`${baseUrl()}/api/runs/${encodeURIComponent(id)}/events`,
);
let paused = false;
let buffer: TraceEvent[] = [];
let counter = 0;
const deliver = (event: TraceEvent) => {
if (paused) {
buffer.push(event);
} else {
onEvent(event);
}
};
const mapType = (type: string): TraceEvent["type"] => {
if (type === "query_fired") return "query";
if (type === "source_visited") return "source_visited";
if (type === "passage_extracted") return "passage_extracted";
if (type === "state_changed") return "status";
if (type === "run_done" || type === "run_started") return "status";
if (type === "run_error") return "status";
return "status";
};
const handle = (backendEvent: { type: string; data: unknown }) => {
let detail = "";
let message = backendEvent.type;
if (typeof backendEvent.data === "string") {
try {
const parsed = JSON.parse(backendEvent.data) as {
type: string;
data: Record<string, string>;
};
message =
parsed.data?.message ??
parsed.data?.query ??
parsed.data?.url ??
parsed.data?.state ??
backendEvent.type;
detail = JSON.stringify(parsed.data);
} catch {
detail = backendEvent.data;
}
}
counter += 1;
deliver({
id: `live-${counter}`,
ts: new Date().toISOString(),
type: mapType(backendEvent.type),
message,
detail,
});
if (backendEvent.type === "run_done" || backendEvent.type === "run_error") {
source.close();
onDone?.();
}
};
source.onmessage = (raw) => {
handle({ type: "message", data: raw.data });
};
for (const type of [
"run_started",
"query_fired",
"source_found",
"source_visited",
"passage_extracted",
"source_refused",
"state_changed",
"probe_attached",
"run_done",
"run_error",
]) {
source.addEventListener(type, (raw) => {
handle({ type, data: (raw as MessageEvent).data });
});
}
return {
cancel() {
source.close();
},
pause() {
paused = true;
},
resume() {
paused = false;
const pending = buffer;
buffer = [];
for (const event of pending) {
onEvent(event);
}
},
};
}
override async getReport(id: string): Promise<Report> {
const { data, error } = await this.#client.GET(
"/api/runs/{run_id}/report",
{ params: { path: { run_id: id } } },
);
if (error || !data) {
throw new Error(`live: report not ready for run ${id}`);
}
const markdown = (data as { run_id: string; markdown: string }).markdown;
return {
id,
runId: id,
title: "Research Report",
sections: [{ heading: "Report", body: markdown }],
citations: [],
};
}
override async getLedger(id: string): Promise<CitationLedger> {
const { data, error } = await this.#client.GET(
"/api/runs/{run_id}/ledger",
{ params: { path: { run_id: id } } },
);
if (error || !data) {
throw new Error(`live: ledger not ready for run ${id}`);
}
const backend = data as {
run_id: string;
generated_at: string;
entries: Array<{
url: string;
visited_at: string;
passages: string[];
claims_supported: number;
health: string;
authority_tier: string;
}>;
};
return {
runId: backend.run_id,
generatedAt: backend.generated_at,
citations: backend.entries.map((entry, index) => ({
id: `c-${index + 1}`,
url: entry.url,
title: entry.url,
site: entry.url,
passageId: entry.passages[0] ?? "",
quote: "",
verification: (entry.health === "live"
? "live"
: entry.health === "stale"
? "stale"
: "dead") as CitationLedger["citations"][number]["verification"],
authorityTier: (entry.authority_tier === "official"
? 1
: entry.authority_tier === "reference"
? 2
: 3) as CitationLedger["citations"][number]["authorityTier"],
})),
};
}
override async exportReport(
id: string,
format: "markdown" | "json",
): Promise<{ filename: string; content: string }> {
const [report, ledger] = await Promise.all([this.getReport(id), this.getLedger(id)]);
if (format === "markdown") {
const body = report.sections.map((s) => `## ${s.heading}\n\n${s.body}`).join("\n\n");
const citationsBlock = ledger.citations
.map(
(c, i) =>
`${i + 1}. [${c.url}](${c.url}) — health: ${c.verification}, tier: ${c.authorityTier}`,
)
.join("\n");
return {
filename: `researchx-${id}.md`,
content:
`# Research Report\n\n${body}\n\n## Citations\n\n${citationsBlock || "(no citations)"}\n`,
};
}
const payload = {
run_id: id,
generated_at: ledger.generatedAt,
entries: ledger.citations.map((c) => ({
url: c.url,
visited_at: ledger.generatedAt,
passages: c.passageId ? [c.passageId] : [],
claims_supported: 1,
health: c.verification,
authority_tier: c.authorityTier === 1 ? "official" : c.authorityTier === 2 ? "reference" : "general",
})),
checklist: {
run_id: id,
citations_checked: ledger.citations.length,
},
};
return {
filename: `researchx-${id}.json`,
content: JSON.stringify(payload, null, 2),
};
}
}
-14
View File
@@ -1,14 +0,0 @@
/** Shared test fixtures for live-binding contract tests. */
export interface BackendRun {
id: string;
status: string;
question: string;
methodology: string;
plan: {
sub_questions: string[];
source_universe: string[];
outline: string[];
} | null;
started_at: string;
}