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
41 changed files with 4027 additions and 279 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"phase": 2,
"stage": "execute",
"phase": 5,
"stage": "complete",
"milestone": "v0.2",
"phase_role": "execution",
"phase_role": "final",
"attempts": 0,
"updated_at": "2026-09-27T11:35: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 -59
View File
@@ -1,77 +1,44 @@
# PLAN — Milestone v0.2 Phase 2: Research Engine + Trust Loop
# PLAN — Milestone v0.2 Phase 5 (FINAL): Review + Audit + Ship
---
phase: 2
phase: 5
milestone: v0.2
status: planned
status: executed
must_haves:
- "Asyncio state machine walks PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING → (depth loop) → SYNTHESIZING → VERIFYING → DONE with typed transitions; pause/cancel/resume survive process restarts via durable JSON state (atomic rewrite per transition + cursor)"
- "Plan = sub-questions + source universe + outline; run halts at AWAITING_APPROVAL until approved (fake-model plan proven end-to-end)"
- "Typed event bus → trace file per run + SSE-ready event stream (query_fired, source_visited, passage_extracted, state_changed, run_done, run_error)"
- "Evidence store: visited pages with stable passage IDs; passage extraction; writer context = stored passages only (visited-only constraint by construction)"
- "Trust loop: post-run URL liveness pass (live/stale/dead); claim-to-passage quote anchoring (normalize + exact short-circuit + SequenceMatcher ≥ 0.85, weak_match flags); citation ledger JSON per run; exportable verification checklist; confidence labels (single-source vs corroborated)"
- "SSRF guard at the fetch boundary (private/loopback/link-local + scheme allowlist) before the engine consumes search results (verify P1 flag, now mandatory)"
- "Run-id generator consolidated to the storage adapter's (reduced alphabet)"
- "Live capability probing against real endpoints when keys exist (REQ-07/12 complete) — probes surfaced as typed results, never crash the run"
- "Zero-API-key: every engine + trust test runs on 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:
- backend-engineer
- data-engineer
- security-engineer
- ci-lead-developer
requirements: [REQ-07, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-18, REQ-19, REQ-20, REQ-21, REQ-22, REQ-23, REQ-24]
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
Still no UI change in P2 (API + client swap land in P3). The P2 surface is the engine itself, exercisable via tests and a documented `uv run python -m researchx.engine.demo` happy-path script (fake adapters): question → plan → approve → search → extract → reflect → synthesize → verify → DONE, artifacts written to a temp runs dir (state.json, trace.jsonl, report.md, ledger.json, checklist.md).
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 (P2 — test + demo script, written before execute)
## Happy Path (owner review script)
1. `cd backend && uv sync && uv run pytest` — all green, zero API keys
2. `uv run python -m researchx.engine.demo` — fake-adapter run walks every state, artifacts land in a temp dir
3. `uv run python -c "..."` — load state.json: state DONE; ledger.json: citations with health + authority tier; checklist.md exportable
4. Pause mid-depth-loop test: cancel task, restart engine from state file, run completes (REQ-15/32 mid-loop seed planted)
5. Adversarial seed: fake writer emits a hallucinated URL → verifier flags it dead; quote mismatch → weak_match/mismatch flagged in ledger (REQ-32 seeds, full suite in P4)
6. Frontend gates still green (untouched)
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 (P2)
## UX Acceptance Criteria
- Trust is visible by construction: every claim in report.md carries a citation marker; the ledger names every cited URL with visited_at, passages, health, authority tier
- Degradation honest: liveness failures, weak matches, and fallback probes surface in typed data, never silent
- No secret value ever reaches a trace, artifact, or error message
- A restarted run resumes, never re-plans from scratch (unless depth cursor says so)
- ≤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)
## What this phase did
### Wave 1 — Evidence store (data-engineer)
- **T-2.1** Evidence models: `researchx/evidence/store.py` — Passage (stable ID: `p-<n>` sequence, url, quote, text), VisitedPage, EvidenceStore (add passage/page, get context bundle for writer: ONLY stored passages, by construction REQ-18); passage extraction from PageContent (paragraph split, min length, dedupe)
- **T-2.2** Evidence tests: stable IDs, dedupe, visited-only context (a passage not stored can never appear in writer context)
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 — Engine (backend-engineer)
- **T-2.3** State machine core: `researchx/engine/states.py` (enum + legal transitions table) + `run_state.py` (RunState pydantic: state, cursor, plan, counters; JSON round-trip) — persisted via storage adapter atomically after every transition (REQ-13, REQ-15)
- **T-2.4** Event bus: `researchx/engine/events.py` — typed pydantic events (discriminated union on type), EventBus with subscribe/publish, trace writer (append-only .jsonl per run) (REQ-16)
- **T-2.5** RunActor: `researchx/engine/actor.py` — one actor per run; walks states; planner call (model adapter, plan = sub-questions + source universe + outline) → AWAITING_APPROVAL halt → approved → SEARCHING (search adapter per dials; query_fired events) → EXTRACTING (fetch + evidence store + SSRF guard; source_visited/passage_extracted events) → REFLECTING depth loop (planner; depth from dials; cursor checkpoint) → SYNTHESIZING (writer; context = evidence bundle ONLY; per-claim markers) → VERIFYING (trust loop) → DONE (persist report + ledger + checklist) (REQ-13, REQ-14, REQ-16, REQ-18)
- **T-2.6** Pause/cancel/resume: asyncio.Event pause gate; cancel persists terminal state; resume from state file re-enters at cursor; CancelledError never swallowed (re-raised after shielded persist) (REQ-15)
- **T-2.7** Engine tests on fakes: full walk, AWAITING_APPROVAL halt + approve, pause/resume mid-depth-loop, cancel, restart-from-state-file, event ordering, visited-only writer context, zero API keys
## Scope Guard
### Wave 3 — Trust loop (backend-engineer + security-engineer)
- **T-2.8** Liveness pass: `researchx/trust/liveness.py` — GET with redirect-follow (10s, size cap from P1 flag), live/stale/dead classification; runs post-run over cited URLs (REQ-19)
- **T-2.9** Quote anchoring: `researchx/trust/anchoring.py` — normalize (NFC/casefold/whitespace collapse) + exact-substring short-circuit + SequenceMatcher ≥ 0.85 per-passage; near-threshold (0.80–0.90) flagged weak_match; mismatches flagged never dropped (REQ-20)
- **T-2.10** Citation ledger + checklist: `researchx/trust/ledger.py` — per-run ledger JSON (url, visited_at, passages, claims supported, health, authority tier); verification checklist export (markdown); confidence labels single-source vs corroborated (REQ-21, REQ-22, REQ-24)
- **T-2.11** SSRF guard: `researchx/trust/url_guard.py` (or adapters/fetch guard) — scheme http/https only; resolve host; refuse private/loopback/link-local/0.0.0.0/IPv6 ULA; wired into every fetch path (search adapter fetch + engine extract) (P1 P1-flag, security persona)
- **T-2.12** Trust tests: liveness classification (MockTransport), anchoring thresholds + weak_match + shared-corpus sentence, ledger schema, checklist export, confidence labels, SSRF refusals (private IP, link-local metadata 169.254.169.254, non-http scheme)
### Wave 4 — Live probing + integration (backend-engineer + ci-lead-developer)
- **T-2.13** Live probe wiring: engine run pre-flight probes model adapter when OLLAMA_API_KEY or local endpoint reachable — probe result + structured-output warning stored in run state + trace; probe failure degrades honestly, never crashes the run (REQ-07, REQ-12 complete)
- **T-2.14** Demo script + integration proofs: researchx.engine.demo (fake adapters, temp dir); end-to-end integration test pinning artifacts (state DONE, report with markers, ledger health, checklist); run-id consolidation (storage adapter's generator everywhere)
- **T-2.15** README: engine architecture section (state machine diagram in text, artifact layout runs/<id>/)
## Out of scope for P2
- API routes/SSE transport/OpenAPI client (P3); adversarial suite expansion + E2E (P4); ETA/cost heuristics (P3, REQ-25 adjacent)
## Verification hooks (consumed by VERIFY)
- Gates: backend ruff/mypy strict/pytest; frontend untouched-green
- REQ coverage: REQ-07, 12, 13..24 each mapped to ≥1 test or artifact proof
- Security: SSRF refusals test-enforced; no value leaks in traces/artifacts; CancelledError discipline
- G-006 note: if this phase slips beyond one extension cycle, split into P2a (engine/SSE artifacts) + P2b (trust) patches — requires orchestrator decision commit
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.
+4 -4
View File
@@ -130,8 +130,8 @@
| REQ-63, REQ-64, REQ-65, REQ-66 (app shell, library, settings, avatar) | v0.1 | P5 | complete |
| REQ-67, REQ-68 (ask suggestions, refine dialogue) | v0.1 | P7 | complete |
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | complete (v0.2.1; REQ-07/12 contracts proven, live probing in P2) |
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | 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-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 |
+27 -10
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.
@@ -93,24 +93,41 @@ Phases renumbered from the original v1.0 plan (1-based within the milestone). Ta
- **Depends on**: 0
- **Status**: complete (PR #10 squash-merged to milestone/v0.2-mvp dfb738d, tag v0.2.1, release HTTP 201, phase branch deleted; verify PASS 0 P0 / 2 P1 fixed in-phase / 9 P2 flagged; REQ-07/12 contracts proven with fakes per G-001, live probing in P2)
### 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
+43 -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
@@ -48,6 +55,25 @@ 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):
@@ -60,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,
)
+25 -11
View File
@@ -13,6 +13,7 @@ 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(
@@ -93,21 +94,34 @@ class DuckDuckGoSearchAdapter:
return re.sub(r"\s+", " ", text).strip()
async def fetch(self, url: str) -> PageContent:
if not url_allowed(url):
raise AdapterError("fetch refused: url not allowed (private or non-http scheme)")
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:
+2
View File
@@ -12,6 +12,8 @@ _REQUEST_TIMEOUT = 60.0
class OllamaModelAdapter:
probing_supported = True
def __init__(
self,
secrets: EnvFileSecretsProvider,
+25 -11
View File
@@ -10,6 +10,7 @@ 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)
@@ -56,18 +57,31 @@ class TavilySearchAdapter:
][:max_results]
async def fetch(self, url: str) -> PageContent:
if not url_allowed(url):
raise AdapterError("fetch refused: url not allowed (private or non-http scheme)")
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")
+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()
+28 -3
View File
@@ -1,4 +1,5 @@
import asyncio
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
@@ -34,6 +35,7 @@ class RunActor:
storage: LocalStorageAdapter,
registry: MethodologyRegistry,
event_bus: EventBus,
on_complete: Callable[[], None] | None = None,
) -> None:
self._config = run_config
self._model = model
@@ -41,6 +43,7 @@ class RunActor:
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,
@@ -83,6 +86,8 @@ class RunActor:
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:
@@ -112,6 +117,15 @@ class RunActor:
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:
@@ -177,10 +191,21 @@ class RunActor:
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 data.get("sub_questions", [])],
source_universe=[str(u) for u in data.get("source_universe", [])],
outline=[str(o) for o in data.get("outline", [])],
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:
+10 -2
View File
@@ -10,23 +10,31 @@ class RunEvent(BaseModel):
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) -> list[RunEvent]:
return list(self._log)
def replay(self, after_seq: int = 0) -> list[RunEvent]:
return [e for e in self._log if e.seq > after_seq]
class TraceWriter:
+37 -8
View File
@@ -49,12 +49,41 @@ def anchor_quote(quote: str, passages: list[Passage]) -> AnchorVerdict:
)
_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]:
marker_re = re.compile(r"\[(p-\d+)\]")
quotes: list[str] = []
for match in marker_re.finditer(report_text):
passage_id = match.group(1)
passage = evidence.get(passage_id)
if passage:
quotes.append(passage.quote)
return quotes
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)]
+32 -5
View File
@@ -4,7 +4,12 @@ from datetime import datetime
from pydantic import BaseModel
from researchx.evidence.store import EvidenceStore, Passage
from researchx.trust.anchoring import anchor_quote, extract_quotes_from_report, normalize
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] = {
@@ -75,15 +80,22 @@ def build_ledger(
) -> CitationLedger:
claims = _claims_per_passage(report_text)
all_passages = evidence.writer_context()
report_quotes = extract_quotes_from_report(report_text, evidence)
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 quote in report_quotes:
verdict = anchor_quote(quote, all_passages)
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:
@@ -91,7 +103,16 @@ def build_ledger(
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)
@@ -148,7 +169,9 @@ 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"]
return ledger.checklist.liveness_ratio >= LIVENESS_EXPORT_GATE, failures
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:
@@ -163,6 +186,10 @@ def export_checklist(ledger: CitationLedger) -> str:
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}",
+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"
+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
+4 -3
View File
@@ -190,10 +190,11 @@ def test_ledger_export_gate() -> None:
from researchx.trust.liveness import LivenessStatus
store = EvidenceStore()
store.add_passage("https://a", "passage one " * 20)
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="[p-1]",
report_text=f"{stored_text} [p-1]",
evidence=store,
liveness={"https://a": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
@@ -204,7 +205,7 @@ def test_ledger_export_gate() -> None:
ledger_dead = build_ledger(
run_id="abcd2345",
report_text="[p-1]",
report_text=f"{stored_text} [p-1]",
evidence=store,
liveness={"https://a": LivenessStatus.DEAD},
visited_at=datetime.now(UTC),
+8 -4
View File
@@ -97,7 +97,10 @@ def _store_with_report() -> tuple[EvidenceStore, str]:
store = EvidenceStore()
for page in CORPUS[:3]:
store.add_page(page.url, page.title, page.text)
report = f"# Report\n\nClaim one [{store.pages()[0].passage_ids[0]}] and claim two [p-1].\n"
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
@@ -121,9 +124,10 @@ async def test_ledger_build_and_checklist_export(monkeypatch) -> None:
visited_at=datetime.now(UTC),
)
assert ledger.run_id == "abcd2345"
assert len(ledger.entries) == len(store.pages())
assert ledger.checklist.citations_checked == len(store.pages())
assert ledger.checklist.live == len(store.pages())
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(
+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;
}