17 Commits

Author SHA1 Message Date
jonathan 57528d7473 Merge pull request 'fix: count a run completed at the DONE transition, not after it (researchx#28)' (#29) from po/fix-cost-counter-race into main
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 32s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m37s
Reviewed-on: #29
2026-10-27 22:36:42 +00:00
jonathan f790435023 Merge branch 'main' into po/fix-cost-counter-race
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 32s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m36s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 32s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 1m35s
2026-10-27 22:35:50 +00:00
jonathan b29105adf3 Merge pull request 'ci: quote the two job names so the gate file parses (researchx#24)' (#27) from po/phase4-ci-trigger2 into main
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 31s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m36s
Reviewed-on: #27
2026-10-27 22:35:39 +00:00
coreci-researchx-po 70243eeb8b fix: count a run completed at the DONE transition, not after it (researchx#28)
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 31s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m35s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 31s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 1m35s
The cost counter was incremented by a watcher that awaited the run task, so it
lagged the status flip: the run read as done while /api/config still counted
zero completed runs. The increment now happens in the same step as the
transition to DONE, so any client that observes the run as done also observes
the counter.

Refs researchx#28
2026-10-27 05:18:51 +00:00
coreci-researchx-po c7ce780edd ci: quote the two job names - the file could not parse
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Failing after 31s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 1m39s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 32s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m38s
The gate file has never parsed: the two job names were plain scalars
containing ': ' (YAML 1.2 plain-scalar rule - a colon followed by a space
ends the scalar and starts a mapping, so the file fails to load). Gitea
cannot build a run from a file it cannot parse, and the tell is a
dispatch POST answered HTTP 500 with an empty body.

Measured at this seat, own PAT, own repo: dispatch on main -> HTTP 500,
dispatch on this branch -> HTTP 500, 0 run records, 0 queued jobs.
Quoting the two names is the whole change; gates, labels, toolchain and
the trigger shape (workflow_dispatch: {} -> workflow_dispatch:) are
unchanged. Evidence and the follow-up dispatch live on researchx#24.
2026-10-27 04:34:06 +00:00
coreci-researchx-po 95681c4b97 ci: workflow_dispatch as a null trigger, not an empty map (researchx#24 - the fleet control) 2026-10-27 03:41:30 +00:00
jonathan 5d9b6fca23 Merge pull request 'ci: fire the pipeline - the trigger shape this instance actually runs (researchx#24)' (#26) from po/phase4-ci-trigger into main
Reviewed-on: #26
2026-10-27 03:34:53 +00:00
coreci-researchx-po ae2584c7f7 ci: pin a runner label that exists (ubuntu-latest) and install uv+pnpm in-file (researchx#24) 2026-10-27 03:29:06 +00:00
coreci-researchx-po de6c4e096c ci: fire the pipeline - bare push trigger and a non-null workflow_dispatch (researchx#24) 2026-10-27 00:59:08 +00:00
jonathan b9108ae873 Merge pull request 'ci: register the phase-4 gate - backend + frontend jobs, one dispatched green run on main opens the code phase (researchx#24)' (#25) from po/phase4-ci-gate into main
Reviewed-on: #25
2026-10-26 23:18:24 +00:00
coreci-researchx-po 01e2d2702c ci: register the phase-4 gating workflow (researchx#24) - backend (uv/Python 3.12: ruff, mypy, pytest) + frontend (Node 22/pnpm 12.6.0: typecheck, lint, test, build); one dispatched run on main with both jobs green opens the code phase 2026-10-26 12:55:48 +00:00
coreci-hap ab1cf5f3f8 Merge pull request 'comms-etiquette v1.0 pass: TL;DR prologues for README + frontend/README (researchx#20)' (#21) from docs/comms-etiquette-tldr-prologues into main 2026-10-19 19:51:57 +00:00
coreci-researchx-po 9ace9d8a02 docs(comms): TL;DR prologue for frontend/README.md (comms-etiquette v1.0; researchx#20) 2026-10-19 19:43:57 +00:00
coreci-researchx-po c1e8e53e5b docs(comms): TL;DR prologue for README.md (comms-etiquette v1.0; researchx#20) 2026-10-19 19:43:51 +00:00
coreci 8db45e478b docs(release): v0.2.5 published — approved milestone release; ledger closed for v0.2
---ci---
phase: 5
milestone: v0.2
status: complete
escalation:
  type: deploy
  hook: deploy
  resolution: auto
  directive: milestone release publication post-approval
  message: >-
    The approved milestone release v0.2.5 published. Gitea release
    retitled to note the recorded approval (decision 4721c2a) and
    G-009 ratification. The deployed container at
    https://research.coreci.dev already serves the release content
    (the v0.2.5-uat staging was built from the same milestone HEAD
    as the merged release) - no redeploy needed. DEPLOY-RECORD
    ledger row added; v0.2 milestone cycle closed.
requirements:
  covered: []
  partial: []
---/ci---
2026-09-29 15:29:21 +00:00
coreci 702d3aae71 chore(milestone): clear checkpoint — v0.2-mvp complete
---ci---
phase: 5
milestone: v0.2
status: complete
message: >-
  Milestone v0.2-mvp COMPLETE and APPROVED. Checkpoint cleared per
  the run workflow: the next /ci-run starts milestone v0.3 at
  phase 0 (pre-execution: specify, clarify, research, plan,
  grill, mvp/ux check), which config.json now points at (tag line
  v0.3.x). v0.3 scope is pending owner direction: live-bound
  library/settings/suggestions/refine surfaces (G-009 ratified
  scope), backend hosting for the LXC deployment, and the remaining
  6 methodologies from the locked catalog.
requirements:
  covered: [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]
  partial: []
---/ci---

Milestone branch will be deleted (local + remote); tags
v0.2.0-v0.2.5 and the main merge (b11b202) preserve the full
history. v0.3 unblocked.
2026-09-29 15:28:52 +00:00
coreci-bot b11b202271 milestone: v0.2 MVP — approved, merge to main (#15) 2026-09-29 15:28:45 +00:00
55 changed files with 5885 additions and 267 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"phase": 1,
"stage": "execute",
"phase": 5,
"stage": "complete",
"milestone": "v0.2",
"phase_role": "execution",
"phase_role": "final",
"attempts": 0,
"updated_at": "2026-09-27T11:05:00Z"
"updated_at": "2026-09-27T16:30:00Z"
}
+2 -1
View File
@@ -215,4 +215,5 @@ 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.
- [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+.
+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**: true (P2 only)
- **Active**: false (P2 shipped — removed per phase-specific policy)
- **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
+26 -63
View File
@@ -1,81 +1,44 @@
# PLAN — Milestone v0.2 Phase 1: Methodology Framework + Adapter Layer
# PLAN — Milestone v0.2 Phase 5 (FINAL): Review + Audit + Ship
---
phase: 1
phase: 5
milestone: v0.2
status: planned
status: executed
must_haves:
- "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"
- "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+"
personas:
- data-engineer
- backend-engineer
- ci-lead-developer
requirements: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-08, REQ-09, REQ-10, REQ-11, REQ-31]
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]
---
## User-Facing Surface
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.
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.
## Happy Path (P1 — test/README script, written before execute)
## Happy Path (owner review script)
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/
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
## UX Acceptance Criteria (P1)
## UX Acceptance Criteria
- 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
- ≤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
## Tasks (waves — dependency-ordered, persona-assigned)
## What this phase did
### 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]
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 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]
## Scope Guard
### 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)
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.
+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 | 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-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-35..51 | v2 backlog (post-MVP milestones) | — | pending |
+28 -11
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) — UNBLOCKED (prototype approved 2026-09-27)
## Milestone v0.2 — MVP (feature) — COMPLETE (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.
@@ -91,26 +91,43 @@ Phases renumbered from the original v1.0 plan (1-based within the milestone). Ta
- **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**: next (in progress — task waves in PLAN.md)
- **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)
### Phase 2 — Research Engine + Trust Loop
### Phase 2 — Research Engine + Trust Loop (complete — shipped v0.2.2)
- **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**: blocked → next (sequential phases; unblock as each prior phase ships)
- **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)
### Phase 3 — API Layer + Integration
### Phase 3 — API Layer + Integration (complete — shipped v0.2.3)
- **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**: blocked → next (sequential phases; unblock as each prior phase ships)
- **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)
### 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).
### 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).
- **Requirements covered**: REQ-17, REQ-32, REQ-33, REQ-34
- **Status**: blocked → next (sequential phases; unblock as each prior phase ships)
- **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)
### 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**: blocked → next (sequential phases; unblock as each prior phase ships)
- **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.
## Milestone Releases
- **v0.1.4 = extended-prototype candidate** (superseded by the P5/P6 extension cycle)
@@ -118,7 +135,7 @@ Phases renumbered from the original v1.0 plan (1-based within the milestone). Ta
- **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.x = MVP release** (MAJOR still 0; ship only if all v0.2 phases complete)
- **v0.2.5 = milestone release** (P5 final phase patch — the approved MVP; owner approval recorded 2026-09-27, G-009 ratified)
- 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
+22 -10
View File
@@ -2,18 +2,23 @@
"ci_version": "0.7.0",
"projects": [],
"active_project": "researchx",
"milestone": "v0.2",
"milestone_name": "mvp",
"milestone": "v0.3",
"milestone_name": "next-milestone-planning",
"milestone_type": "feature",
"versioning": {
"major_locked": true,
"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)"
"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)"
},
"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
@@ -37,9 +42,16 @@
"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"
]
}
}
}
}
+28 -5
View File
@@ -1,8 +1,8 @@
# Deploy Record — UAT stagings of the v0.1 prototype
# Deploy Record — UAT stagings and releases
Owner-directed UAT deployments, recorded per commit discipline. The
approval gate (REQ-62) stays closed; UAT variants stage review builds
without minting release tags.
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.
| Date | Version | Commit | Directive | Result |
|---|---|---|---|---|
@@ -11,6 +11,8 @@ 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)
@@ -24,4 +26,25 @@ 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)
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
+87
View File
@@ -0,0 +1,87 @@
# 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 … | `bash .ciagent/release/deploy.sh v0.2.x` |
| v0.2 MVP | v0.2.0 … v0.2.5 | `bash .ciagent/release/deploy.sh v0.2.5-uat` (staged) — see DEPLOY-RECORD.md |
## Rules
+157
View File
@@ -0,0 +1,157 @@
# .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
+63 -1
View File
@@ -1,8 +1,15 @@
# 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 in development (milestone v0.2)** — the approved v0.1 prototype frontend (mock-backed) plus the FastAPI backend, research engine, adapter layer, and trust loop. The frontend runs standalone on mock data; live backend mode arrives with v0.2 phase 3.
**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+.
## Quickstart
@@ -28,6 +35,45 @@ Three clicks from cold start to initiated research.
| 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):
@@ -40,6 +86,22 @@ All keys resolve from env files only (`.env`, `.env.secrets`, `.env.*` — never
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 -1
View File
@@ -17,6 +17,7 @@ dev = [
"mypy>=1.11",
"pytest>=8",
"pytest-asyncio>=0.23",
"httpx>=0.28.1",
]
[build-system]
@@ -40,5 +41,8 @@ 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"]
testpaths = ["tests"]
@@ -3,7 +3,6 @@ import json
import httpx
from researchx.adapters.model import ModelAdapter
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.probe import (
DEFAULT_EMBEDDING_BATCH,
CapabilityProbe,
@@ -13,19 +12,25 @@ from researchx.adapters.probe import (
from researchx.adapters.types import ModelMessage
async def probe_model_adapter(adapter: OllamaModelAdapter) -> CapabilityProbe:
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=adapter.active_base_url,
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=adapter.last_fallback_reason,
fallback_reason=fallback_reason,
)
+27 -10
View File
@@ -7,11 +7,13 @@ import httpx
from researchx.adapters.secrets import SecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter, extract_text, extract_title
from researchx.adapters.types import PageContent, SearchResult
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(
@@ -92,19 +94,34 @@ class DuckDuckGoSearchAdapter:
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=True,
follow_redirects=False,
headers={"User-Agent": _USER_AGENT},
) as client:
response = await client.get(url)
response.raise_for_status()
return PageContent(
url=url,
title=extract_title(response.text),
text=extract_text(response.text),
fetched_at=datetime.now(UTC),
)
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:
@@ -58,4 +58,16 @@ class LocalStorageAdapter:
try:
return target.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise AdapterError(f"artifact not found: runs/{run_id}/{name}") from 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
+2
View File
@@ -12,6 +12,8 @@ _REQUEST_TIMEOUT = 60.0
class OllamaModelAdapter:
probing_supported = True
def __init__(
self,
secrets: EnvFileSecretsProvider,
+26 -9
View File
@@ -5,10 +5,12 @@ 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)
@@ -55,16 +57,31 @@ class TavilySearchAdapter:
][: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=True,
follow_redirects=False,
headers={"User-Agent": _USER_AGENT},
) as client:
response = await client.get(url)
response.raise_for_status()
return PageContent(
url=url,
title=extract_title(response.text),
text=extract_text(response.text),
fetched_at=datetime.now(UTC),
)
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")
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,358 @@
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
@@ -0,0 +1,85 @@
"""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()
+373
View File
@@ -0,0 +1,373 @@
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
@@ -0,0 +1,68 @@
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
@@ -0,0 +1,49 @@
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
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,70 @@
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)
@@ -11,11 +11,12 @@ _RAPID_PLANNER_PROMPT = (
_RAPID_WRITER_PROMPT = (
"You are a rapid memo writer. Answer the question directly in a short memo.\n"
"Cite a source per claim. Every cited quote must come verbatim from the provided "
"evidence passages.\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}"
"Plan: {plan}\n"
"Evidence passages:\n{evidence}"
)
_DEEP_PLANNER_PROMPT = (
@@ -30,11 +31,12 @@ _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. Every cited quote must come verbatim from the provided "
"evidence passages.\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}"
"Plan: {plan}\n"
"Evidence passages:\n{evidence}"
)
_BUILTIN: dict[str, Methodology] = {
@@ -21,8 +21,12 @@ class Methodology(BaseModel, frozen=True):
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) -> str:
return self.writer_prompt.format(question=question, context=context, plan=plan)
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"]
+89
View File
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,208 @@
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
@@ -0,0 +1,65 @@
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
+359
View File
@@ -0,0 +1,359 @@
"""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
@@ -0,0 +1,156 @@
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"
+183
View File
@@ -0,0 +1,183 @@
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
@@ -0,0 +1,56 @@
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") == []
+64
View File
@@ -0,0 +1,64 @@
"""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
+11 -1
View File
@@ -72,9 +72,19 @@ def test_prompt_templates_render_placeholders() -> None:
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")
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:
+215
View File
@@ -0,0 +1,215 @@
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
@@ -0,0 +1,129 @@
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
+105
View File
@@ -0,0 +1,105 @@
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"]
+159
View File
@@ -0,0 +1,159 @@
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
+2
View File
@@ -521,6 +521,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -538,6 +539,7 @@ requires-dist = [
[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" },
+7
View File
@@ -1,5 +1,12 @@
# 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
@@ -0,0 +1,652 @@
{
"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"
}
}
}
}
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@researchx/frontend",
"version": "0.1.8",
"version": "0.2.5",
"private": true,
"packageManager": "pnpm@12.6.0",
"scripts": {
@@ -30,8 +30,11 @@
"@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",
+305 -123
View File
@@ -168,7 +168,7 @@ importers:
dependencies:
next:
specifier: 15.5.6
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)
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)
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@7.2.0)
version: 3.3.7(supports-color@10.2.2)
'@tailwindcss/postcss':
specifier: 4.1.18
version: 4.1.18
@@ -205,19 +205,28 @@ importers:
version: 19.2.3(@types/react@19.2.5)
'@vitejs/plugin-react':
specifier: 4.7.0
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))
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))
eslint:
specifier: ^9.39.5
version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
version: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint-config-next:
specifier: 15.5.6
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)
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
globals:
specifier: ^17.0.0
version: 17.12.0
jsdom:
specifier: 26.1.0
version: 26.1.0(supports-color@7.2.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)
tailwindcss:
specifier: 4.1.18
version: 4.1.18
@@ -226,10 +235,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@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
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)
vitest:
specifier: 3.2.7
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)
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)
packages:
@@ -841,6 +850,16 @@ 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==}
@@ -1390,6 +1409,10 @@ 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'}
@@ -1482,6 +1505,9 @@ 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}
@@ -1526,6 +1552,9 @@ 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'}
@@ -1540,6 +1569,9 @@ 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==}
@@ -1826,6 +1858,14 @@ 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'}
@@ -2006,6 +2046,10 @@ 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'}
@@ -2134,6 +2178,10 @@ 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==}
@@ -2164,6 +2212,9 @@ 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==}
@@ -2318,6 +2369,10 @@ 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==}
@@ -2402,6 +2457,18 @@ 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'}
@@ -2422,6 +2489,10 @@ 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==}
@@ -2454,6 +2525,10 @@ 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'}
@@ -2515,6 +2590,10 @@ 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'}
@@ -2684,6 +2763,10 @@ 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'}
@@ -2759,6 +2842,10 @@ 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'}
@@ -2803,6 +2890,9 @@ 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==}
@@ -2952,6 +3042,13 @@ 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'}
@@ -2978,20 +3075,20 @@ snapshots:
'@babel/compat-data@7.29.7': {}
'@babel/core@7.29.7(supports-color@7.2.0)':
'@babel/core@7.29.7(supports-color@10.2.2)':
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@7.2.0))(supports-color@7.2.0)
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.9
'@babel/template': 7.29.7
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/traverse': 7.29.8(supports-color@10.2.2)
'@babel/types': 7.29.8
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -3016,19 +3113,19 @@ snapshots:
'@babel/helper-globals@7.29.7': {}
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
'@babel/helper-module-imports@7.29.7(supports-color@10.2.2)':
dependencies:
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/traverse': 7.29.8(supports-color@10.2.2)
'@babel/types': 7.29.8
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-module-imports': 7.29.7(supports-color@10.2.2)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/traverse': 7.29.8(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -3049,14 +3146,14 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
'@babel/plugin-transform-react-jsx-self@7.29.7(@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@10.2.2))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-react-jsx-source@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@10.2.2))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-plugin-utils': 7.29.7
'@babel/runtime@7.29.7': {}
@@ -3067,7 +3164,7 @@ snapshots:
'@babel/parser': 7.29.9
'@babel/types': 7.29.8
'@babel/traverse@7.29.8(supports-color@7.2.0)':
'@babel/traverse@7.29.8(supports-color@10.2.2)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
@@ -3075,7 +3172,7 @@ snapshots:
'@babel/parser': 7.29.9
'@babel/template': 7.29.7
'@babel/types': 7.29.8
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -3203,17 +3300,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@7.2.0))':
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))':
dependencies:
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.21.2(supports-color@7.2.0)':
'@eslint/config-array@0.21.2(supports-color@10.2.2)':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -3226,10 +3323,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/eslintrc@3.3.7(supports-color@7.2.0)':
'@eslint/eslintrc@3.3.7(supports-color@10.2.2)':
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -3435,6 +3532,29 @@ 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':
@@ -3677,15 +3797,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@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/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)':
dependencies:
'@eslint-community/regexpp': 4.12.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)
'@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/scope-manager': 8.70.1
'@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/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/visitor-keys': 8.70.1
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
ignore: 7.0.10
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -3693,23 +3813,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@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/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)':
dependencies:
'@typescript-eslint/scope-manager': 8.70.1
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.70.1
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.70.1(supports-color@7.2.0)(typescript@5.9.3)':
'@typescript-eslint/project-service@8.70.1(supports-color@10.2.2)(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@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -3723,13 +3843,13 @@ snapshots:
dependencies:
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/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)':
dependencies:
'@typescript-eslint/types': 8.70.1
'@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)
'@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)
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -3737,13 +3857,13 @@ snapshots:
'@typescript-eslint/types@8.70.1': {}
'@typescript-eslint/typescript-estree@8.70.1(supports-color@7.2.0)(typescript@5.9.3)':
'@typescript-eslint/typescript-estree@8.70.1(supports-color@10.2.2)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.70.1(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/project-service': 8.70.1(supports-color@10.2.2)(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@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
@@ -3752,13 +3872,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@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/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)':
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))
'@typescript-eslint/scope-manager': 8.70.1
'@typescript-eslint/types': 8.70.1
'@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-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: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -3838,11 +3958,11 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@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))':
'@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))':
dependencies:
'@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))
'@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))
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
@@ -3907,6 +4027,8 @@ 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:
@@ -4015,6 +4137,10 @@ 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
@@ -4067,6 +4193,8 @@ 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: {}
@@ -4077,6 +4205,8 @@ snapshots:
color-name@1.1.4: {}
colorette@1.4.0: {}
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
@@ -4121,17 +4251,17 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
debug@3.2.7(supports-color@7.2.0):
debug@3.2.7(supports-color@10.2.2):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
supports-color: 10.2.2
debug@4.4.3(supports-color@7.2.0):
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
supports-color: 10.2.2
decimal.js@10.6.0: {}
@@ -4326,19 +4456,19 @@ snapshots:
escape-string-regexp@4.0.0: {}
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):
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):
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@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))
'@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))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -4346,52 +4476,52 @@ snapshots:
- eslint-plugin-import-x
- supports-color
eslint-import-resolver-node@0.3.10(supports-color@7.2.0):
eslint-import-resolver-node@0.3.10(supports-color@10.2.2):
dependencies:
debug: 3.2.7(supports-color@7.2.0)
debug: 3.2.7(supports-color@10.2.2)
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@7.2.0))(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@10.2.2))(supports-color@10.2.2):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
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@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-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)
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@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):
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):
dependencies:
debug: 3.2.7(supports-color@7.2.0)
debug: 3.2.7(supports-color@10.2.2)
optionalDependencies:
'@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)
'@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)
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@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-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):
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@7.2.0)
debug: 3.2.7(supports-color@10.2.2)
doctrine: 2.1.0
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)
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)
hasown: 2.0.4
is-core-module: 2.17.0
is-glob: 4.0.3
@@ -4403,13 +4533,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@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@10.2.2))(supports-color@10.2.2)(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@7.2.0)):
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)):
dependencies:
aria-query: 5.3.2
array-includes: 3.2.0
@@ -4419,7 +4549,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@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
hasown: 2.0.4
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -4428,11 +4558,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@7.2.0)):
eslint-plugin-react-hooks@5.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)):
dependencies:
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
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)):
eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)):
dependencies:
array-includes: 3.2.0
array.prototype.findlast: 1.2.5
@@ -4440,7 +4570,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@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2)
estraverse: 5.3.0
hasown: 2.0.4
jsx-ast-utils: 3.3.5
@@ -4465,14 +4595,14 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0):
eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2):
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2(supports-color@7.2.0)
'@eslint/config-array': 0.21.2(supports-color@10.2.2)
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.7(supports-color@7.2.0)
'@eslint/eslintrc': 3.3.7(supports-color@10.2.2)
'@eslint/js': 9.39.5
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
@@ -4482,7 +4612,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -4528,6 +4658,12 @@ 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: {}
@@ -4674,17 +4810,17 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
http-proxy-agent@7.0.2(supports-color@7.2.0):
http-proxy-agent@7.0.2(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6(supports-color@7.2.0):
https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -4705,6 +4841,8 @@ snapshots:
indent-string@4.0.0: {}
index-to-position@1.2.0: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4844,6 +4982,8 @@ snapshots:
jiti@2.7.0: {}
js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -4852,14 +4992,14 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@26.1.0(supports-color@7.2.0):
jsdom@26.1.0(supports-color@10.2.2):
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@7.2.0)
https-proxy-agent: 7.0.6(supports-color@7.2.0)
http-proxy-agent: 7.0.2(supports-color@10.2.2)
https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28
parse5: 7.3.0
@@ -4885,6 +5025,8 @@ 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:
@@ -5007,6 +5149,10 @@ 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: {}
@@ -5017,7 +5163,7 @@ snapshots:
natural-compare@1.4.0: {}
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):
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):
dependencies:
'@next/env': 15.5.6
'@swc/helpers': 0.5.15
@@ -5025,7 +5171,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@7.2.0))(react@19.2.8)
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.8)
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.6
'@next/swc-darwin-x64': 15.5.6
@@ -5093,6 +5239,22 @@ 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
@@ -5121,6 +5283,12 @@ 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
@@ -5141,6 +5309,8 @@ snapshots:
picomatch@4.0.7: {}
pluralize@8.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
@@ -5211,6 +5381,8 @@ 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: {}
@@ -5461,12 +5633,14 @@ snapshots:
dependencies:
js-tokens: 9.0.1
styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.8):
styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.8):
dependencies:
client-only: 0.0.1
react: 19.2.8
optionalDependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/core': 7.29.7(supports-color@10.2.2)
supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
@@ -5530,6 +5704,8 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@4.41.0: {}
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -5562,13 +5738,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@7.2.0))(supports-color@7.2.0)(typescript@5.9.3):
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):
dependencies:
'@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-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: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -5617,14 +5793,16 @@ 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@7.2.0):
vite-node@3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@10.2.2):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
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)
@@ -5656,7 +5834,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@7.2.0))(lightningcss@1.30.2)(supports-color@7.2.0):
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):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
@@ -5667,7 +5845,7 @@ snapshots:
'@vitest/spy': 3.2.7
'@vitest/utils': 3.2.7
chai: 5.3.3
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3(supports-color@10.2.2)
expect-type: 1.4.0
magic-string: 0.30.21
pathe: 2.0.3
@@ -5679,11 +5857,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@7.2.0)
vite-node: 3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@10.2.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.3.1
jsdom: 26.1.0(supports-color@7.2.0)
jsdom: 26.1.0(supports-color@10.2.2)
transitivePeerDependencies:
- jiti
- less
@@ -5775,4 +5953,8 @@ 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\.1\.8 · prototype/i)).toBeInTheDocument();
expect(screen.getByText(/v0\.2\.5 · MVP release candidate/i)).toBeInTheDocument();
});
});
+3 -2
View File
@@ -244,8 +244,9 @@ export function SettingsView() {
visited-only citations, three clicks to a trustworthy report.
</p>
<p className="mt-2 text-caption text-ink-faint">
v0.1.8 · prototype — runs on scripted mock data; the engine,
adapters, and trust loop arrive in v0.2.
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).
</p>
</section>
+40 -4
View File
@@ -26,6 +26,7 @@ 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 {
@@ -66,7 +67,8 @@ export interface CreateRunInput {
/**
* Subscription handle for the live trace stream (EventSource-shaped).
* v0.2 backs this with SSE; pause/resume stay client-side concerns.
* v0.2: backed by SSE in live mode; client-side pause/resume kept
* (server-side pause/resume added via the P3 extension below).
*/
export interface RunEventSubscription {
/** Stop the stream and release its timers (idempotent). */
@@ -77,6 +79,18 @@ 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.
@@ -181,7 +195,29 @@ export interface ApiClient {
/**
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
* event replay, zero API keys). v0.2 P3: rebind to the OpenAPI-generated
* client — this line is the only thing that changes.
* 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.
*/
export const api: ApiClient = new MockApiClient();
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();
+215
View File
@@ -0,0 +1,215 @@
/**
* @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
@@ -0,0 +1,628 @@
/**
* 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
@@ -0,0 +1,157 @@
/**
* 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
@@ -0,0 +1,390 @@
/**
* 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
@@ -0,0 +1,14 @@
/** 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;
}