36 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
coreci 42435bc3b3 chore(milestone): clear checkpoint — v0.1-prototype complete
---ci---
phase: 8
milestone: v0.1
status: complete
message: >-
  Milestone v0.1-prototype COMPLETE and APPROVED. Checkpoint cleared
  per the run workflow: the next /ci-run starts milestone v0.2-mvp
  at phase 0 (pre-execution: specify, clarify, research, plan),
  which config.json now points at (tag line v0.2.x).
requirements:
  covered: [REQ-52, REQ-53, REQ-54, REQ-55, REQ-56, REQ-57, REQ-58, REQ-59, REQ-60, REQ-61, REQ-62, REQ-63, REQ-64, REQ-65, REQ-66, REQ-67, REQ-68]
  partial: []
---/ci---

Milestone branch deleted (local + remote); tags v0.1.0-v0.1.8 and the
main merge (d6ddcfe) preserve the full history. v0.2-mvp unblocked.
2026-09-27 10:06:08 +00:00
coreci cd01901183 docs(release): v0.1.8 published — approved milestone release deployed
---ci---
phase: 8
milestone: v0.1
status: complete
escalation:
  type: deploy
  hook: deploy
  resolution: auto
  directive: milestone release publication post-approval
  message: >-
    The approved milestone release v0.1.8 deployed to the LXC host
    (plain tag, not -uat): gates green at the tagged tree, container
    swapped, health check passed. Gitea release renamed to note the
    recorded approval. DEPLOY-RECORD ledger updated.
requirements:
  covered: [REQ-62]
  partial: []
---/ci---

Fifth deploy row: v0.1.8 (release) -> 93a7b57 at the tag. The
container at http://10.0.0.114:3000 now serves the approved
prototype.
2026-09-27 10:05:55 +00:00
coreci d6ddcfeb82 Merge milestone/v0.1-prototype: approved prototype (v0.1.8)
Milestone v0.1 — Prototype — COMPLETE.

Owner approval recorded 2026-09-27 (decision commit 3fd0e10) on the
extended prototype: v0.1.8. Phases P0–P8 (two owner-feedback
extension cycles), requirements REQ-52..62 + REQ-63..68 complete.

Delivered: Ask/Choose/Plan & Run/Reading Room inside a persistent app
shell (sidebar + top bar + avatar), folder-structured research library
with cross-report connections, settings surface, open-question
suggestions, refine dialogue — all behind the typed ApiClient
boundary, 90/90 tests, mock-backed (zero throwaway for v0.2).

Next: milestone v0.2 — MVP (FastAPI backend, engine, adapters,
trust loop, OpenAPI client swap).
2026-09-27 10:03:20 +00:00
coreci 5a8de00d63 docs(milestone): complete v0.1-prototype — approved and released
---ci---
phase: 8
milestone: v0.1
status: complete
requirements:
  covered: [REQ-52, REQ-53, REQ-54, REQ-55, REQ-56, REQ-57, REQ-58, REQ-59, REQ-60, REQ-61, REQ-62, REQ-63, REQ-64, REQ-65, REQ-66, REQ-67, REQ-68]
  partial: []
---/ci---

Milestone v0.1-prototype complete: REQ-62 owner approval recorded
(3fd0e10); REQUIREMENTS traceability all complete; ROADMAP marks
the milestone complete with the phase ledger; config.json advances
to milestone v0.2-mvp (tag line v0.2.x) — the MVP is unblocked.
Merging to main next; milestone branches are deleted after (tags
v0.1.0–v0.1.8 preserve history).
2026-09-27 10:03:16 +00:00
coreci 3fd0e10734 decision(P08): owner approval recorded — prototype approved at the REQ-62 gate
---ci---
phase: 8
milestone: v0.1
status: complete
escalation:
  type: prototype_approval
  hook: prototype_approval
  resolution: approved
  decided_by: owner
  decided_at: 2026-09-27
  message: >-
    The owner has recorded approval of the extended prototype
    (v0.1.8): the 5-screen click-path inside the app shell
    (sidebar + top bar + avatar), the folder-structured research
    library with cross-report connections, the settings surface,
    the open-question suggestions, and the refine dialogue —
    reviewed live on the staged UAT build and in the releases.
    All milestone requirements (REQ-52..61, REQ-63..68) are
    approved. This decision unblocks: milestone merge to main,
    v0.1.8 publication as the milestone release, and the v0.2
    MVP milestone.
requirements:
  covered: [REQ-52, REQ-53, REQ-54, REQ-55, REQ-56, REQ-57, REQ-58, REQ-59, REQ-60, REQ-61, REQ-62, REQ-63, REQ-64, REQ-65, REQ-66, REQ-67, REQ-68]
  partial: []
---/ci---

Approved via the owner's recorded /ci-run directive ('approved').
Milestone v0.1-prototype now completes: merge to main, milestone
branch deletion (tags preserve history), v0.2 unblocked.
2026-09-27 10:02:55 +00:00
coreci 57c5231d88 docs(P08): final phase shipped — v0.1.8-uat staged; pipeline halts at approval gate
---ci---
phase: 8
milestone: v0.1
status: approval_pending
escalation:
  type: prototype_approval
  hook: prototype_approval
  resolution: pending_owner_decision
  message: >-
    v0.1.8 milestone release candidate shipped and presented. UAT
    staged per owner cadence (v0.1.7-uat mid-cycle, v0.1.8-uat at
    gate). On recorded approval: milestone merge to main, milestone
    branches deleted, v0.2 unblocked. On rejection: feedback becomes
    binding decisions for a fix cycle.
requirements:
  covered: [REQ-52, REQ-53, REQ-54, REQ-55, REQ-56, REQ-57, REQ-58, REQ-59, REQ-60, REQ-61, REQ-63, REQ-64, REQ-65, REQ-66, REQ-67, REQ-68]
  partial: [REQ-62]
---/ci---

Ship record: PR #8 squash-merged phase/08-final-review-approval-ship →
milestone/v0.1-prototype (93a7b57), tag v0.1.8, phase branch deleted
(local+remote), Gitea release v0.1.8 created (HTTP 201), v0.1.8-uat
deployed healthy to the LXC host (gates re-run at deploy time, all
routes 200, suggestions + refine affordances served). DEPLOY-RECORD
row added; ROADMAP P8 → complete-pending-approval. Checkpoint stays
approval_pending at the REQ-62 gate — the milestone merge to main
awaits the owner's recorded decision.
2026-09-27 05:30:28 +00:00
coreci-bot 93a7b5755c docs(P08): complete 2nd final review + audit + approval-gate phase (#8)
Squash-merge of phase/08-final-review-approval-ship into milestone/v0.1-prototype. Review PASS-WITH-FIXES (double-compose + drawer-keyboard P1s, 90/90). Audit PASS-WITH-NOTES (all applied). Tag v0.1.8 = extended-prototype release candidate. Milestone merge to main HALTS at the REQ-62 owner approval gate.
2026-09-27 05:27:37 +00:00
coreci b7c1eb89a6 docs(release): record v0.1.7-uat LXC staging (owner cadence directive)
---ci---
phase: 8
milestone: v0.1
status: execute
escalation:
  type: deploy
  hook: deploy
  resolution: auto
  directive: UAT cadence after P7 and P8 (owner fork decision)
  message: >-
    v0.1.7-uat deployed to the LXC Docker host per the owner-selected
    cadence. Gates re-run green at deploy time; container swapped,
    health check passed; /ask serves the suggestions + refine
    affordances. Approval gate UNCHANGED.
requirements:
  covered: []
  partial: [REQ-62]
---/ci---

DEPLOY-RECORD.md: third UAT staging row (v0.1.7-uat -> 432d2c0).
Owner can try suggestions + refine mid-cycle at
http://10.0.0.114:3000 before the P8 final review pass.
2026-09-27 05:07:22 +00:00
coreci 432d2c0eb0 docs(P07): phase 7 shipped — checkpoint advances to phase 8 (final)
---ci---
phase: 7
milestone: v0.1
status: complete
requirements:
  covered: [REQ-67, REQ-68]
  partial: []
---/ci---

Ship record: PR #7 squash-merged phase/07-ask-suggestions-refine →
milestone/v0.1-prototype (fe82508), tag v0.1.7, phase branch deleted
(local+remote), Gitea release v0.1.7 created (HTTP 201). REQUIREMENTS
traceability REQ-67/68 → complete; ROADMAP P7 → complete, P8 → next.
P8 re-presents the extended prototype at the REQ-62 owner approval
gate: review the full milestone diff, audit, tag v0.1.8, UAT deploy
per owner cadence directive, halt for the recorded decision.
2026-09-27 05:04:05 +00:00
coreci-bot fe82508131 feat(P07): ask screen intelligence — suggestions + refine dialogue (#7)
Squash-merge of phase/07-ask-suggestions-refine into milestone/v0.1-prototype. REQ-67 (open-question suggestions, click-fills-box) + REQ-68 (refine dialogue, scripted exchange, composed question). Verify: PASS — 88/88 tests, 2 P1 fixes (Enter-guard, focus return) with regression tests. Ships as v0.1.7.
2026-09-27 05:03:38 +00:00
coreci 2652630a37 docs(release): record v0.1.6-uat LXC staging (owner directive)
---ci---
phase: 6
milestone: v0.1
status: approval_pending
escalation:
  type: deploy
  hook: deploy
  resolution: auto
  directive: push updates to lxc container
  message: >-
    Owner-directed UAT deploy of the extended prototype to the LXC
    Docker host via the codified release system. Gates re-run green
    (typecheck/lint/75 tests/build) at deploy time; image
    researchx:v0.1.6-uat swapped in; health check + route smoke
    passed. Approval gate UNCHANGED - the REQ-62 owner decision on
    the milestone merge to main is still pending.
requirements:
  covered: [REQ-52, REQ-53, REQ-54, REQ-55, REQ-56, REQ-57, REQ-58, REQ-59, REQ-60, REQ-61, REQ-63, REQ-64, REQ-65, REQ-66]
  partial: [REQ-62]
---/ci---

DEPLOY-RECORD.md (new): ledger of owner-directed UAT stagings.
v0.1.6-uat -> 600b49c, healthy at http://10.0.0.114:3000 with the
app shell, library, and settings verified in served HTML. Checkpoint
remains approval_pending.
2026-09-27 04:42:19 +00:00
coreci-bot 600b49c2fa docs(P06): complete final review + audit + approval-gate phase (#6)
Squash-merge of phase/06-final-review-approval-ship into milestone/v0.1-prototype. Review PASS-WITH-FIXES (deep-link P0 + 5 P1s, 75/75 tests). Audit PASS-WITH-NOTES (all notes applied). Tag v0.1.6 = extended-prototype release candidate. Milestone merge to main HALTS at the REQ-62 owner approval gate.
2026-09-27 04:37:36 +00:00
coreci a118f359a7 docs(P05): phase 5 shipped — checkpoint advances to phase 6 (final)
---ci---
phase: 5
milestone: v0.1
status: complete
requirements:
  covered: [REQ-63, REQ-64, REQ-65, REQ-66]
  partial: []
---ci---

Ship record: PR #5 squash-merged phase/05-app-shell-reports-settings →
milestone/v0.1-prototype (b44bffc), tag v0.1.5, phase branch deleted
(local+remote), Gitea release v0.1.5 created (HTTP 201). REQUIREMENTS
traceability REQ-63..66 → complete; ROADMAP P5 → complete, P6 → next.
P6 re-presents the extended prototype at the REQ-62 owner approval
gate: review the full milestone diff, audit, tag v0.1.6, halt for
the owner's recorded decision.
2026-09-27 03:24:04 +00:00
coreci-bot b44bffcd7e feat(P05): app shell + research library + settings + avatar (#5)
Squash-merge of phase/05-app-shell-reports-settings into milestone/v0.1-prototype. REQ-63..66 covered. Verify: PASS (72/72 tests, P1 main-landmark fix in-phase). Ships as v0.1.5.
2026-09-27 03:23:42 +00:00
coreci 0fa0b07429 fix(P04): deploy.sh — UAT branch lookup strips patch segment; SSH-key auth preferred
---ci---
phase: 4
milestone: v0.1
status: execute
persona: ci-lead-developer
task: T-4.2
requirements:
  covered: [REQ-52]
  partial: []
---/ci---

UAT variant now resolves the milestone branch by milestone number
(v0.1) not full tag version (v0.1.4), matching branch-strategy
labeling. DEPLOY_KEY (path in .env.secrets) preferred over passwords —
survives sshd PermitRootLogin prohibit-password (Ubuntu 24.04 default
that rejects root password auth; observed on the LXC host). Deployed
v0.1.4-uat for manual UAT: http://10.0.0.114:3000.
2026-09-27 02:48:41 +00:00
coreci 2ffc71cff9 feat(P04): codified docker release system — deploy.sh + Dockerfile + versioning
---ci---
phase: 4
milestone: v0.1
status: execute
persona: ci-lead-developer
task: T-4.2
requirements:
  covered: [REQ-52]
  partial: []
---/ci---

Codified release workflow for future releases: .ciagent/release/deploy.sh
(one-command tag-anchored deploy: gates at tag, worktree build, sync to
Docker host, image build, rolling container swap with researchx-prev
rollback, health check), frontend/Dockerfile (multi-stage, Next.js
standalone, non-root runtime), VERSIONING.md release rules, README.
Transport: sshpass OR native SSH_ASKPASS; rsync OR tar-over-ssh.
Version argument supports <tag>-uat variants staging milestone HEAD
(tag + review infra) without minting release tags — used for manual
UAT deployment while the owner approval gate stays closed. Secrets
(DEPLOY_HOST/USER/PASSWORD) resolve only from .ciagent/.env.secrets.
2026-09-27 02:33:12 +00:00
coreci 7db53d625b docs(P04): final phase shipped — pipeline HALTED at owner approval gate (REQ-62)
---ci---
phase: 4
milestone: v0.1
status: complete
escalation:
  type: prototype_approval
  hook: prototype_approval
  resolution: pending_owner_decision
  message: >-
    Prototype complete and presented for approval. On recorded approval:
    milestone merge to main, milestone branches deleted, v0.2 unblocked.
    On rejection: feedback becomes binding decisions for a fix cycle.
requirements:
  covered: [REQ-52, REQ-53, REQ-54, REQ-55, REQ-56, REQ-57, REQ-58, REQ-59, REQ-60, REQ-61]
  partial: [REQ-62]
---/ci---

Ship record: PR #4 squash-merged phase/04-final-review-approval-ship →
milestone/v0.1-prototype (1d97788), tag v0.1.4, phase branch deleted
(local+remote), Gitea release v0.1.4 created (HTTP 201) with full
milestone summary. Review: PASS-WITH-FIXES, 5 P0s fixed in-phase.
Audit: PASS. Per REQ-62 and the prototype_approval escalation hook,
the milestone merge to main does NOT proceed autonomously — the
pipeline halts here for the recorded owner decision.
2026-09-27 02:24:03 +00:00
coreci-bot 1d97788e5d docs(P04): complete final review + audit + approval-gate phase
Final phase: full-milestone review PASS-WITH-FIXES (5 P0s fixed with tests), audit PASS, PLAN/CHECKPOINT advance. REQ-62 owner approval gate: milestone merge to main + v0.1.4 publication halt for recorded owner approval. Gates 42/42 green.
2026-09-27 02:23:44 +00:00
coreci b03f57bc56 docs(P03): phase 3 shipped — checkpoint advances to phase 4 (final)
---ci---
phase: 3
milestone: v0.1
status: complete
requirements:
  covered: [REQ-60, REQ-61]
  partial: []
---/ci---

Ship record: PR #3 squash-merged phase/03-mockups-polish-3click →
milestone/v0.1-prototype (d15568f), tag v0.1.3, phase branch deleted
(local+remote), Gitea release v0.1.3 created (HTTP 201). REQUIREMENTS
traceability REQ-60/61 → complete; ROADMAP P3 → complete, P4 → in
progress. Next: final phase (review + audit + P1-flag fixes), then the
owner approval gate halts the milestone before merge to main.
2026-09-27 01:53:49 +00:00
132 changed files with 12797 additions and 565 deletions
+2 -2
View File
@@ -47,7 +47,7 @@ FastAPI routes: POST /api/runs, GET /api/methodologies, GET /api/runs/{id}, appr
- **DependsOn:** engine, methodologies, config
### 7. Frontend (Next.js App Router, TypeScript strict)
Screens: Ask (question + optional context), Choose (methodology cards with ETA/cost/why-recommended; dials behind "advanced"), Plan & Run (approve/edit plan, live SSE trace, pause/cancel), Reading Room v0 (rendered report, hover-a-citation → exact quoted passage, ledger panel, Markdown/JSON export), plus v2 surface mockups (project graph, task inbox) in the prototype.
App shell (root layout, P5): persistent sidebar (primary nav — New research / Library / Settings; adapter connection-status dots as a v2 preview) + top bar (⌘K navigation palette — screens + stored reports; avatar button → user menu with the local single-user profile). Screens: Ask (question + optional context; open-question suggestions below the form — follow-ups from unanswered questions of past runs, click-fills-the-box, REQ-67; opt-in Refine dialogue — inline drawer, scripted clarifying exchange, composed refined question + suggested context, REQ-68), Choose (methodology cards with ETA/cost/why-recommended; dials behind "advanced"), Plan & Run (approve/edit plan, live SSE trace, pause/cancel), Reading Room v0 (rendered report, hover-a-citation → exact quoted passage, ledger panel, Markdown/JSON export), Library (P5: folder-structure report storage — auto-categorized folders, recency/title sorting, report cards into the Reading Room; cross-report Connections panel — shared-sources ⇄ / related-topic → edges; project graph + task inbox as v2-preview panels), Settings (P5: masked API-key status, storage directory, default methodology + resolved 12-dial display, appearance preview, about/version).
- **Boundaries:** consumes the typed `ApiClient` interface only — components never import the mock client directly. In v0.1 the interface is bound to `MockApiClient` (fixture data + scripted event replay); in v0.2 phase 3 the binding swaps to the OpenAPI-generated client with no component changes.
- **DependsOn:** ApiClient contract (v0.1: mock; v0.2: API layer)
@@ -58,7 +58,7 @@ v0.1 (prototype): Ask (question + context) → Choose (methodology slug + dial o
v0.2 (MVP): Ask (question + context) → Choose (methodology slug + dial overrides) → POST /api/runs → engine PLANNING (planner model via model adapter, dial bundle resolved) → AWAITING_APPROVAL (plan persisted; SSE events; user approves/edits) → SEARCHING (search adapter, routing per dials) → EXTRACTING (fetch pages → evidence store, stable passage IDs) → REFLECTING (planner model; depth loop per dials) → SYNTHESIZING (writer model; context = evidence passages only, per-claim citation markers) → VERIFYING (liveness pass, quote anchoring, confidence labels) → DONE (report + ledger + checklist persisted to storage; Reading Room renders; export).
## Build Order
Milestone v0.1 (prototype): 1. Frontend scaffold + ApiClient/MockApiClient boundary → 2. Ask + Choose → 3. Plan & Run + Reading Room v0 → 4. v2 mockups + polish + 3-click validation → 5. review + owner approval gate + ship.
Milestone v0.1 (prototype): 1. Frontend scaffold + ApiClient/MockApiClient boundary → 2. Ask + Choose → 3. Plan & Run + Reading Room v0 → 4. v2 mockups + polish + 3-click validation → 5. review + owner approval gate (owner returned gate feedback → extension cycle: 5. app shell + research library + settings + avatar → 6. final review + approval re-presentation (2nd owner gate feedback: 7. ask suggestions + refine dialogue → 8. final re-review + ship)).
Milestone v0.2 (MVP, gated on v0.1 approval):
1. Methodology framework (dial schema + 2 seed methodologies + registry)
+6 -6
View File
@@ -1,8 +1,8 @@
{
"phase": 3,
"stage": "verify",
"milestone": "v0.1",
"phase_role": "execution",
"phase": 5,
"stage": "complete",
"milestone": "v0.2",
"phase_role": "final",
"attempts": 0,
"updated_at": "2026-09-27T02:00:00Z"
}
"updated_at": "2026-09-27T16:30:00Z"
}
+219
View File
@@ -0,0 +1,219 @@
# CIAgent Grill Report
## Run: 2026-09-27 11:25 (mode: mechanical, focus: all)
### Verdict: Proceed (confidence: 0.72)
The plan is sound with fixable gaps. The evidence base is unusually strong for a project at P0: a shipped and owner-approved v0.1 (9 tags, decision commit 3fd0e10), a locked two-milestone structure, a typed frontend boundary already in the tree (client.ts), a research file with concrete API shapes, and honest `---ci---` reporting throughout. But the grill found **two internal contradictions and one measurability hole** that would each have surfaced at P3 as rework: (1) PLAN.md P1 claims REQ-07/REQ-12 delivery while ROADMAP/REQUIREMENTS/PLAN's own frontmatter map them to P2; (2) the approved prototype's ApiClient documents seven v0.2 endpoint mappings (`GET /api/graph`, `GET /api/tasks`, `GET /api/library`, `GET /api/library/connections`, `GET /api/suggestions`, `POST /api/refine/*`, `PUT /api/config`) that REQ-25 does not include — a hidden-scope grenade wired to the P3 swap; (3) REQ-23's ≥99% liveness bar cannot be proven in the zero-key test suite it is scheduled into. All three are absorbed below as binding decisions. The MVP does not overreach as specced — REQ-01..34 is a genuine core loop — but the *implicit* live-mode expectation (whole prototype UI working against the backend) does overreach, and is explicitly reduced to the core research loop (G-002, escalated for owner ratification at the P5 gate).
---
### Axis 1 — Business Case
- **Q1**: What problem does this actually solve, and is that problem still the top priority?
- Evidence: PROJECT.md Core Value + Key Decisions ("deep-research agents hallucinate URLs at the highest measured rates; verification cuts failures to <1% [E1]"); owner approved v0.1.8 and immediately unblocked v0.2 (commits 3fd0e10, 42435bc).
- Answer: Real, named problem — citation-untrustworthy deep-research agents — converted into a differentiated product thesis (methodology-first + trust loop). The owner's same-day unblocking of v0.2 shows it remains the top priority. Not a project that outlived its justification: the value is entirely unrealized until the MVP ships, which argues for proceeding, not stopping.
- Confidence: 0.80
- **Q2**: Who is the named executive sponsor, and when did they last make a decision under pressure?
- Evidence: Owner decisions under pressure recorded twice in v0.1: P4 gate returned *feedback instead of approval* → binding P5/P6 extension (ROADMAP P4 note, commit 7db53d62); P6 gate returned *2nd feedback* → P7/P8 extension (commit 57c5231, ROADMAP P6 note); final approval 3fd0e10. D-018/D-019 record owner directives for this run.
- Answer: The sponsor is pressure-tested, not a rubber stamp — two rejections/extensions before approval. This is the strongest possible sponsor signal available in the record.
- Confidence: 0.85
- **Q3**: What happens to the business if the project is cancelled?
- Evidence: v0.1 is mock-only by design (REQ-53: "no backend, no API keys"); zero research value has shipped; all value lands in v0.2 (REQ-01..34).
- Answer: Cancelling now strands 100% of sunk effort — the prototype is a shell without the MVP. The project is not a zombie; it is the opposite: a project whose entire value is still ahead of it, which raises the stakes of P2/P3 execution quality, not the case for stopping.
- Confidence: 0.75
- **Q4**: Is the ROI calculated against a counterfactual?
- Evidence: MISSING — no ROI/counterfactual calculation exists anywhere in .ciagent/.
- Answer: No formal ROI. The implicit counterfactual is "ad-hoc LLM prompting with unverified citations," which is plausible but never quantified. For an owner-directed, single-user, local-first tool this is a tolerable omission — the sponsor is the beneficiary — but it means priority calls rest entirely on owner judgment, with no objective measure if enthusiasm fades.
- Confidence: 0.55
- Decision: G-007 — make the counterfactual *measurable in-product*: per-run request/token/cost counters in the run trace, surfaced via `GET /api/config` (advisory, no hard spend cap in MVP). This is the cheapest honest ROI instrumentation available.
### Axis 2 — Scope and Requirements
- **Q1**: Is the scope expanding, contracting, or genuinely stable?
- Evidence: REQ-01..34 locked with traceability (REQUIREMENTS.md table); D-007..D-020 clarify commit recorded (e79102b); v0.1 scope *grew twice* via owner gate feedback (P5/P7 extensions) — proof the gate can grow scope.
- Answer: The formal scope is stable and frozen. But the grill found the *informal* scope is ambiguous: the prototype's approved UI implies backend endpoints that REQ-25 does not promise (see Q4). That ambiguity will resolve itself at P3 — either as scope growth or as a visible degraded mode. It must be resolved now, in writing.
- Confidence: 0.60
- **Q2**: Who owns the requirements, and have they been frozen?
- Evidence: Owner-approved milestone structure (PROJECT.md Constraints); REQUIREMENTS.md traceability with per-phase status; clarify decisions D-007..D-020 at confidence 0.75–1.0.
- Answer: Owner owns; the pipeline freezes and traces them. Requirements management is this project's strongest discipline.
- Confidence: 0.85
- **Q3**: What is explicitly out of scope?
- Evidence: REQUIREMENTS.md "Out of Scope" table (paywalled sources, chatbot/hosting, auth/multi-tenancy, native Ollama adapter, rename, autonomous v1.0.0); PROJECT.md Out of Scope.
- Answer: Explicit, reasoned, and enforced (e.g., OpenAI-compatible-only is restated in constraints and architecture).
- Confidence: 0.90
- **Q4**: Are there hidden requirements only disclosed late in delivery?
- Evidence: **FOUND — two.** (a) `frontend/src/lib/api/client.ts` lines 123–179 document v0.2 mappings to `GET /api/graph`, `GET /api/tasks`, `GET /api/library`, `GET /api/library/connections`, `GET /api/suggestions`, `POST /api/refine/questions`, `POST /api/refine/compose`, `PUT /api/config` — none of which appear in REQ-25 (runs, methodologies, lifecycle, SSE, config-read only) or any v0.2 phase mapping. (b) The same file exposes no `pauseRun`/`resumeRun` while REQ-25 mandates pause/resume in the API and REQ-15 mandates server-side pause/resume across restarts; line 69's comment claims "pause/resume stay client-side concerns," which contradicts the engine requirement.
- Answer: The P3 swap ("behind the unchanged ApiClient interface," REQ-26/REQ-54) is wired to detonate: either the backend grows 7+ undocumented endpoints (scope expansion inside the MVP milestone), or live mode silently breaks six approved UI surfaces, or the interface changes anyway (breaking REQ-54's zero-rewrite promise without a plan). This is the single largest finding of this grill.
- Confidence: 0.50
- Decision: G-002 — declare the v0.2 live-mode surface = **core research loop only** (Ask, Choose, Plan & Run, Reading Room + read-only `GET /api/config`); Library, Settings persistence, Suggestions, Refine, project graph, task inbox, and user profile stay mock-bound behind ApiClient in live mode, honestly marked in-UI, with their backends explicitly deferred to v0.3+. G-003 — add `pauseRun`/`resumeRun` to ApiClient in P3 as the single planned, documented interface extension (mock gains matching implementations), superseding the v0.1 client-side-pause comment; REQ-54's zero-rewrite promise is amended to "no *unplanned* rewrites."
- Escalation: [G-009] — owner must ratify the live-mode boundary at the P5 gate (see Escalations).
### Axis 3 — Architecture and Technical Feasibility
- **Q1**: Has the architecture been validated by the people who will build and operate it?
- Evidence: RESEARCH-v02.md — 8 topics with concrete API payload shapes, pitfalls, and 14 conclusions written for the builders (pydantic event unions, shielded state writes, `os.replace` atomicity, pytest-asyncio modes, hatchling `packages` key). But `backend/` does not exist yet — zero lines of the architecture have run.
- Answer: Design-side validation is strong (this is what a builder-facing research doc should look like); runtime validation is zero, by definition of P0. The risk concentrates at first contact: ollama.com/v1 shapes, Tavily payload, SSE backpressure. P1 Wave 3 is the first live proof.
- Confidence: 0.65
- **Q2**: What is the integration surface, and where does it quietly double in cost?
- Evidence: Five adapter slots (ARCHITECTURE §2); SSE transport (D-014); OpenAPI client generation (D-013, RESEARCH §7 — flags operation-id stability and regen-from-snapshot discipline as pitfalls); the ApiClient surface gap (Axis 2 Q4).
- Answer: 5 adapters + SSE + codegen swap = 7 integration seams. The cost-doubling candidate is the P3 swap (seam #7) — precisely where the undocumented-endpoint problem sits. Second candidate: the openapi.json snapshot discipline exists only as a research *pitfall note*, not a P3 must-have.
- Confidence: 0.60
- Decision: G-005 — P3 must-have: commit the backend `openapi.json` snapshot; regenerate `frontend/src/lib/api/generated/` only from the snapshot (never a live server); add a schema-drift check to the gates; fix FastAPI operation-ids at route creation.
- **Q3**: Is there an existing system being replaced — actual data volume, transaction rate, edge-case behavior?
- Evidence: Greenfield backend; the "replaced system" is `MockApiClient` (fixtures + scripted replay, frontend/src/lib/api/mock/). REQ-54 defines the swap contract; PERSONAS.md binds the frontend-engineer to it.
- Answer: No data migration risk. The migration is one interface binding line (client.ts:187) — provided G-002/G-003 are absorbed; otherwise the "small migration" is the biggest one in the project.
- Confidence: 0.70
- **Q4**: What technical debt is inherited, and is it budgeted for?
- Evidence: (a) the v0.1 "client-side pause" comment (client.ts:69) contradicting REQ-15 — budgeted via G-003. (b) DDG `html.duckduckgo.com/html` scrape as the keyless fallback (D-012, confidence 0.75) — RESEARCH §3 itself warns "rate-limits aggressively... best-effort." (c) Scripted-replay SSE shape must match D-014's event union — verified aligned (D-014 rationale cites the MockApiClient replay contract).
- Answer: The DDG fallback is the fragility hotspot: a legacy HTML surface, conservatively scraped, that the research file already expects to fail. The zero-config promise of the product rests partly on it. Mitigation exists in design (surfaced warning, never hang) but is not yet test-enforced.
- Confidence: 0.55
- Decision: G-008 — DDG fallback is tested against recorded HTML fixtures only (never live-scraped in CI); the degradation contract (partial/empty results + typed warning, never hang, never block the engine) is asserted by test. DDG is documented as degraded-mode, not a reliable second source.
### Axis 4 — People, Skills, and Organization
- **Q1**: Which 2–3 people, if they left, would the project fail?
- Evidence: The entire delivery capability is one agent pipeline (coreci) + one owner (decision commit 3fd0e10; all directives owner-recorded). PERSONAS.md is a persona roster, not a human team.
- Answer: Key-person dependency is total and structural: owner disengagement stalls every gate by design (that is the governance model); pipeline failure stops all delivery. For a single-user, owner-directed tool this is acceptable — but it means timeline estimates are really "pipeline availability" estimates, and there is no bench.
- Confidence: 0.60
- **Q2**: Are resources actually allocated at the percentages claimed?
- Evidence: Autonomy level "full" (config.json); v0.1 shipped 9 phases, PRs #1–#8, tags v0.1.0–v0.1.8 within ~10 hours of wall clock (commits dd2538f 00:44 → d6ddcfe 10:03, 2026-09-27).
- Answer: The pipeline is demonstrably 100% allocated and not fighting BAU fires — velocity is exceptional and verified by artifact trail, not status reports.
- Confidence: 0.85
- **Q3**: Is there a product owner with actual authority to prioritize?
- Evidence: Owner directives recorded as binding decisions (D-018, D-019 at confidence 1.0); two gate rejections prove authority in practice.
- Answer: Unambiguous single owner; no committee. Model is working.
- Confidence: 0.90
- **Q4**: Is the team building capability it doesn't have, hoping to learn as they go?
- Evidence: The stack is being built in-milestone: FastAPI, asyncio durability (shielded writes, CancelledError discipline — RESEARCH §5), sse-starlette, httpx adapters, openapi codegen. No backend code exists yet (repo is frontend-only today). RESEARCH-v02.md documents the failure modes in advance (pytest-asyncio event_loop removal, hatchling src-layout wheel pitfall, uvicorn single-worker constraint).
- Answer: Yes, capability is being built on the fly — acceptable here because (a) this is a prototype-to-MVP transition with research-backed patterns, and (b) zero-key fakes (REQ-31) mean the engine can be proven without live services. But P2 is where "learning asyncio under load" meets "13 requirements" — see Axis 5.
- Confidence: 0.60
### Axis 5 — Timeline and Estimates
- **Q1**: Was the deadline set before or after scope and approach were understood?
- Evidence: No wall-clock deadlines exist anywhere in ROADMAP v0.2 — sequencing is dependency-driven (P0→P5), cadence is owner gates (D-018: halt at the owner approval gate).
- Answer: No reverse-engineered deadline — the healthiest possible pattern. The only "date" is the owner's attention, which v0.1 proved is present.
- Confidence: 0.85
- **Q2**: What is the critical path, and what single thing would push it by 3+ months?
- Evidence: Critical path P1 → P2 → P3 → P4 (sequential phases, ROADMAP). P2 is the milestone's heaviest phase: 13 requirements (REQ-07, 12, 13–24) including the durable asyncio state machine, trust loop, and live probing. The 3-month pusher is a P3 integration-drift discovery: the ApiClient surface gap (Axis 2 Q4) hitting mid-swap, after P2's engine is built against the wrong boundary assumptions.
- Answer: Critical path is P2-heavy and P3-fragile. G-002/G-003 defuse the P3 risk; P2's size needs a pressure valve.
- Confidence: 0.70
- Decision: G-006 — permit P2 to ship as two patches (P2a: engine + durable state + SSE; P2b: trust loop + live probe/REQ-07/12) or run mid-phase wave gates, without changing milestone scope; the P3 swap plan (endpoint list per G-002) must be drafted at P2 *start*, not P3 start, so drift surfaces before the engine is frozen.
- **Q3**: Are the estimates evidence-based?
- Evidence: The only velocity base is v0.1: ~1 phase/hour sustained across 9 phases including review+audit each. v0.2 phases are materially heavier (greenfield backend, adversarial tests). MISSING: any per-phase sizing in ROADMAP/PLAN.
- Answer: No estimates exist to be wrong — but also none to be right. v0.1 velocity does not transfer 1:1 (frontend screens vs. a durable async engine); treating it as a base rate would understate P2. The mitigation is the phase-patch mechanism (G-006) rather than fake numbers, which this grill endorses over invented estimates.
- Confidence: 0.60
- **Q4**: Is there a working definition of done?
- Evidence: Per-phase success criteria (ROADMAP); PLAN.md gates + "REQ coverage assert: each REQ mapped to ≥1 test or contract proof"; verify stage precedent (PASS-WITH-FIXES with in-phase P0 fixes, PRs #4/#6/#8); final owner gate.
- Answer: Yes, and it is enforced — v0.1 reviews fixed 5 P0s and multiple P1s *in-phase*, which is proof the DoD has teeth.
- Confidence: 0.85
- Decision: G-001 — resolve a DoD contradiction inside P1's own plan: PLAN.md must_haves and tasks T-1.10/T-1.11 claim REQ-07/REQ-12 delivery ("GET /models + structured-output smoke test" = live probing) while PLAN's frontmatter (`requirements: [REQ-01..06, 08..11, 31]`), ROADMAP P1 ("REQ-07, REQ-12 ship in P2... live probing against real endpoints in P2"), and REQUIREMENTS traceability all map them to P2. Binding: P1 ships the typed ProbeResult + structured-output warning *contracts* proven with fake adapters; live endpoint probing executes in P2 with the engine. Secondary bookkeeping fix in the same decision: the "REQ-13..24" range notation includes REQ-17 in both P2 and P4 rows — REQ-17 (3-click on real stack) stays in P4; ranges are corrected to exclude it.
### Axis 6 — Budget and Financial Realism
- **Q1**: What percentage of budget is spent vs. remaining?
- Evidence: MISSING — no cost ledger exists. Known spends: pipeline tokens (v0.1: 9 phases of agent execution; v0.2 P0 stages), Ollama cloud key (config.json secrets scope `model`), Tavily key (scope `search`), self-hosted LXC infra (DEPLOY-RECORD.md).
- Answer: Untracked. For a single-user local-first tool the absolute numbers are small, but "untracked" is still a finding: no one can currently answer what a Deep Report run costs.
- Confidence: 0.60
- **Q2**: Are there predictable cost drivers not in the original budget?
- Evidence: Deep Report = depth 3/breadth 2 → per run: ~dozens of model calls (planner + depth-loop reflections + writer + verification passes) plus search calls; Ollama cloud is pay-per-use; Tavily free tier is quota-capped; DDG fallback is free-but-fragile (D-012). Localhost:11434 fallback is the zero-cost escape hatch (REQ-08).
- Answer: Cost drivers are real and unbounded per run (no request caps on loops beyond dial defaults). The local-fallback design keeps the project from *hard* dependency on spend, but there is no visibility.
- Confidence: 0.60
- Decision: G-007 — per-run counters (model requests, tokens, search calls) recorded in the run trace and surfaced via `GET /api/config`; advisory only in MVP.
- **Q3**: Burn rate and runway?
- Evidence: MISSING formal tracking; mitigated by local-first defaults (keyless localhost fallback, DDG keyless fallback) meaning the runway floor is "zero marginal cost, degraded mode."
- Answer: The architecture is honest about this: the product's default posture degrades to free. Runway is effectively unbounded if the owner accepts degraded mode; the grill accepts this as sound for the MVP.
- Confidence: 0.70
- **Q4**: Is the budget contingent on something that hasn't happened yet?
- Evidence: No — no sale/board/hiring dependency. Keys exist (config.json scopes), infra exists (LXC), pipeline is running.
- Answer: Not contingent. Proceed.
- Confidence: 0.80
### Axis 7 — Risks, Assumptions, and Dependencies
- **Q1**: Top 3 assumptions, and evidence for each?
- Evidence: (a) *Adapter endpoint compatibility* — ollama.com/v1 OpenAI-shape and Tavily POST payload researched with concrete shapes (RESEARCH §2/§3), but unvalidated live in-repo; P1 Wave 3 is the test. (b) *DDG fallback stability* — RESEARCH §3 itself rates it "best-effort, aggressively rate-limited" (weakest assumption in the plan; D-012 confidence 0.75). (c) *Zero-rewrite client swap* — typed boundary exists (client.ts), RESEARCHX_API_MODE designed (D-020), but the interface gaps (Axis 2 Q4) undercut it until G-002/G-003 land.
- Answer: Two of three are well-evidenced; the DDG assumption is the one the project's own research argues against relying on. All three have named first-contact phases (P1 Wave 3, P2, P3).
- Confidence: 0.60
- Decision: G-008 (fixture-tested DDG degradation contract) — see Axis 3 Q4.
- **Q2**: External dependencies outside the team?
- Evidence: Tavily quota; Ollama cloud key validity; DDG uncontrolled HTML surface; none of these are under the project's control; all have fallbacks *except* Tavily→DDG quality.
- Answer: Dependencies are acknowledged with degradation paths — the correct posture. The unmitigated residual is *degraded search quality* when Tavily is exhausted, which directly attacks the ≥99% liveness bar.
- Confidence: 0.65
- **Q3**: The single risk that kills the project, and is there a mitigation?
- Evidence: P3 swap discovering the ApiClient/surface drift after the engine is frozen (evidence chain: client.ts:123–179 vs REQ-25; ROADMAP P3 "the ApiClient interface absorbs any prototype-era API drift" — an assertion, not a plan).
- Answer: Kill risk = integration drift at P3 → rework spiral through a hand-rolled engine → owner patience (already tested twice by extensions) exhausts. Mitigation now exists in writing: G-002 (boundary declared), G-003 (interface change planned), G-005 (snapshot discipline), G-006 (P3 plan drafted at P2 start). This grill's core job is done if those four hold.
- Confidence: 0.65
- **Q4**: Has a pre-mortem been done?
- Evidence: MISSING — no pre-mortem artifact. This grill is the closest proxy.
- Answer: "It's 12 months out and v0.2 failed" — the derivable causes, in order: (1) P3 surface gap hit late (mitigated: G-002/003/005/006); (2) P2 asyncio durability bugs (pause/resume across restarts is genuinely hard — mitigated by RESEARCH §5 patterns + REQ-32 pause-mid-depth-loop adversarial tests); (3) quote anchoring thrash on real noisy pages (difflib 0.85 is fixture-proven only — live-page behavior unknown until P4/live runs); (4) DDG dead + Tavily exhausted → un-demoable zero-config mode (mitigated: G-008 + owner-held Tavily key).
- Confidence: 0.60
### Axis 8 — Governance, Decision-Making, and Communication
- **Q1**: Who is the decision-maker when two parties disagree?
- Evidence: Single owner; all decisions recorded as decision commits (3fd0e10; D-007..D-020 in e79102b); escalation hooks codified in config.json (deploy, delete_data, merge_to_main, prototype_approval).
- Answer: Unambiguous. No dual-executive stall is possible in this structure.
- Confidence: 0.90
- **Q2**: Governance cadence and escalation pattern — useful in a crisis?
- Evidence: Per-phase ship + verify + audit with `---ci---` blocks; v0.1 precedent: pipeline *halted itself* at gates twice (7db53d62, 57c5231) rather than self-approving — the escalation pattern is proven under the exact stress it exists for.
- Answer: Cadence is per-phase and gates have been exercised under pressure. Crisis-tested by construction.
- Confidence: 0.85
- **Q3**: What is being omitted from status reports?
- Evidence: Honest reporting verified in history: "PASS-WITH-FIXES," "PASS-WITH-NOTES," "approval_pending," P0s disclosed and fixed in-phase; DEPLOY-RECORD.md as a separate ledger. The one systemic omission this grill found lives in *spec comments, not status blocks*: the client.ts v0.2 endpoint mappings that were never promoted into REQUIREMENTS — a silent scope assumption, now surfaced (G-002).
- Answer: Status reporting is honest. Spec-side implication drift is the channel where optimism leaked — and it's now on the record.
- Confidence: 0.80
- **Q4**: Is there a stop-the-project trigger?
- Evidence: Owner rejection at gate → binding fix cycle (v0.1 exercised twice); v1.0.0 owner-only (config.json major_locked); D-018 halts at the v0.2 gate; D-019 forbids deploys without directive.
- Answer: Yes — and rejection has demonstrably not become politically impossible; the owner said "not yet, extend" twice without ceremony.
- Confidence: 0.85
### Axis 9 — Change, Adoption, and Operational Readiness
- **Q1**: Who will use this, how does their work change, and what's in it for them?
- Evidence: Single-user owner-researcher (PROJECT.md constraint: "Single-user, local-first"); work change = ad-hoc prompting → structured, reproducible methodology runs with auditable citations (REQ-04 dial snapshots, REQ-21 ledger, REQ-22 checklist).
- Answer: The user is the sponsor — adoption politics are minimal, and the value proposition (trustable citations) is the same thesis the sponsor approved twice. The real adoption test is whether live-mode runs *feel* better than the mock — which lands at P4/REQ-17.
- Confidence: 0.80
- **Q2**: Is ops involved now, or handed a finished product at go-live?
- Evidence: The pipeline *is* ops: deploy.sh with rolling swap + `researchx-prev` rollback container (commit 2ffc71c), gates re-run at deploy time (DEPLOY-RECORD.md rows), VERSIONING.md rules. Gap: the deploy system is frontend-only; the MVP's live mode requires a local backend process (uvicorn, single worker — RESEARCH §4) whose serving story at the owner gate is undefined (D-019 forbids deploys without directive, so live-mode *presentation* mode is an open question).
- Answer: Ops is built-in and rollback-capable for the frontend. The backend has no deploy story — coherent with local-first, but it means the P5 owner-gate demo of the *live* MVP needs a plan (local backend + keys at demo time, or gate runs on mock mode). This rides with G-009.
- Confidence: 0.60
- **Q3**: What is the rollback plan?
- Evidence: Tags v0.1.0–v0.1.8 preserve history; deploy.sh swaps containers with a `researchx-prev` rollback; durable run state is per-run JSON on local disk (D-017) — engine changes cannot corrupt other runs' state; frontend mock mode (RESEARCHX_API_MODE=mock default, D-020) is itself a rollback for the entire live integration.
- Answer: Multi-layer rollback exists and is codified. The mock-default flag is an unusually good one — the MVP can ship with live mode opt-in, making go-live risk nearly zero.
- Confidence: 0.80
- **Q4**: Have success criteria been validated with the people who will judge success?
- Evidence: REQ-17 (≤3 clicks on real stack) is P4-tested, validated on mock in v0.1 (REQ-61 pinned by test). REQ-23 (≥99% citation liveness) — **measurement gap**: the entire adversarial suite is zero-key on a 5–8 page fixture corpus (RESEARCH §5b); 99% liveness on fixture URLs is vacuous, and no keyed/live validation run is scheduled anywhere in P1–P5.
- Answer: The 99% bar — the project's headline promise — is currently scheduled to be "verified" in a harness that cannot verify it. If the owner gate treats REQ-23 as CI-green, the status report will be honest-looking but hollow.
- Confidence: 0.55
- Decision: G-004 — REQ-23 is defined as a **runtime per-run gate** enforced at export (failures listed in the verification checklist, REQ-22) plus an opt-in, keyed, live-URL test tier (skipped by default, run before ships at owner discretion); P4 proves the *gate mechanics* with fixtures and explicitly does not claim live 99% attainment. The MVP definition of done for REQ-23 = the gate exists and enforces, not "CI showed 99%."
### Meta — Closing Review
- **Q1**: If you were the auditor, what would you flag?
- Evidence: The two internal contradictions (PLAN vs ROADMAP/REQUIREMENTS on REQ-07/12, PLAN must_haves vs PLAN frontmatter; REQ-17 double-mapped in range notation) and the spec-comment endpoint mappings outside REQ-25 (client.ts:123–179).
- Answer: Flag #1: phase-mapping integrity — the traceability table is the single source of truth and PLAN must conform to it (G-001). Flag #2: an approved artifact (client.ts) documents v0.2 API surface that the v0.2 spec does not contain — the grill treats this as the most consequential process lapse found, because it would have surfaced as a P3 "surprise" after the engine was frozen.
- Confidence: 0.75
- **Q2**: What is the project not doing that it should?
- Evidence: Missing items found across axes: openapi snapshot drift check as a gate (G-005), per-run cost counters (G-007), live-URL test tier for REQ-23 (G-004), P3 swap plan at P2 start (G-006), fixture-based DDG tests (G-008), live-mode boundary declaration (G-002).
- Answer: All six are now bound. Nothing found suggests the project is doing something it should not.
- Confidence: 0.75
- **Q3**: What is the simplest version that delivers 80% of the value?
- Evidence: The MVP already trimmed to 2 of 8 methodologies (REQ-03) and a core-loop-only API (REQ-25). The remaining 20% of complexity carrying outsized risk: Deep Report's depth-3 breadth-2 loop (multiplies engine, trust, and cost surface) vs. Rapid Answer's depth-1 single-pass core.
- Answer: Rapid Answer alone + trust loop = 80% of the thesis proof. Deep Report is the pre-agreed deferral lever if P2 overruns — decided *now*, while it is cheap, rather than mid-crisis.
- Confidence: 0.65
- Decision: G-010 — contingency lever, pre-agreed: if P2/P4 slip beyond one extension cycle, Deep Report (REQ-03 partial) defers to v0.3 and the MVP ships Rapid Answer end-to-end; the lever requires owner sign-off when pulled, per gate rules.
- **Q4**: What must be true for this to succeed in the next 90 days, and is it true today?
- Evidence: Required: (a) researched adapter shapes hold live (untested — first proof P1 Wave 3); (b) owner engagement continues (proven: 3 gate decisions in one day); (c) Tavily key active/quota available (held in secrets scope; unverified live); (d) the P3 boundary is as declared in G-002 (currently *not* true in writing — client.ts says otherwise).
- Answer: Three of four are true or evidenced; (d) becomes true when the orchestrator promotes G-002/G-003. That promotion is the gating action of this entire grill.
- Confidence: 0.70
---
### Binding Decisions
| ID | Axis | Decision | Confidence |
|----|------|----------|-----------|
| G-001 | 2/5 | Resolve the REQ-07/REQ-12 phase contradiction in PLAN.md's favor of the ROADMAP/traceability mapping: P1 ships typed probe/warning contracts proven with fakes; live endpoint probing (GET /models, structured-output smoke test) executes in P2. Reword PLAN must_haves T-1.10/T-1.11 to contract level; fix the REQ-13..24 range notation so REQ-17 appears only in P4. | 0.85 |
| G-002 | 2/3 | Declare v0.2 live-mode surface = core research loop only (Ask, Choose, Plan & Run, Reading Room, read-only GET /api/config). Library, Settings persistence, Suggestions, Refine, project graph, task inbox, user profile stay mock-bound in live mode, honestly marked; their backends are v0.3+ scope. Update client.ts v0.2-mapping comments accordingly. | 0.80 |
| G-003 | 3 | Add `pauseRun`/`resumeRun` to the ApiClient interface in P3 as the single planned, documented interface extension (mock gains matching implementations); run-level pause is server-side per REQ-15/REQ-25, superseding the v0.1 "client-side concerns" comment. REQ-54 amended: no *unplanned* rewrites. | 0.80 |
| G-004 | 7/9 | REQ-23 (≥99% liveness) is a runtime per-run gate enforced at export and surfaced in the verification checklist — not a zero-key CI assertion. Add an opt-in keyed live-URL test tier (skipped by default). P4 proves gate mechanics with fixtures; MVP DoD for REQ-23 = the gate exists and enforces. | 0.75 |
| G-005 | 3/5 | P3 must-have: commit the backend openapi.json snapshot; regenerate frontend/src/lib/api/generated/ only from the snapshot; add a schema-drift check to gates; fix FastAPI operation-ids at route creation. | 0.85 |
| G-006 | 5 | Permit P2 (13 REQs — heaviest phase) to ship as two patches (P2a engine+durable state+SSE, P2b trust loop+live probe) or run mid-phase wave gates without milestone scope change; draft the P3 swap endpoint plan (per G-002) at P2 start, not P3 start. | 0.70 |
| G-007 | 1/6 | Per-run advisory counters (model requests, tokens, search calls) recorded in the run trace and surfaced via GET /api/config — the cheapest honest ROI/burn instrumentation; no hard spend cap in MVP. | 0.70 |
| G-008 | 3/7 | DDG fallback tested against recorded HTML fixtures only (never live-scraped in CI); degradation contract (partial/empty + typed warning, never hang the engine) asserted by test; DDG documented as degraded-mode, not a reliable second source. | 0.70 |
| G-010 | Meta | Pre-agreed contingency lever: if P2/P4 slip beyond one extension cycle, Deep Report (depth 3) defers to v0.3 and the MVP ships Rapid Answer end-to-end; pulling the lever requires owner sign-off at a gate. | 0.65 |
### Escalations
- [G-009] Axis 2 — Owner ratification required at the P5 gate (D-018 halt): the v0.2 MVP demonstrates the **live core research loop only**; the remaining approved-prototype surfaces (Library, Settings persistence, Suggestions, Refine, graph/inbox previews, profile) remain mock-bound until v0.3+, and the live-mode gate demo requires a local backend + keys (or runs in mock mode) since D-019 forbids deploys. This redefines what "MVP done" looks like at the demo and must not be discovered by the owner at the gate — it must be ratified in advance. Engineering-side handling is bound in G-002/G-003; the ratification itself is unresolved until the owner records it.
- **RESOLVED 2026-09-27: RATIFIED.** Owner approval recorded via /ci-run 'APPROVED' at the v0.2.5 gate (decision commit 4721c2a), following the owner-directed UAT staging at https://research.coreci.dev. Live-mode scope = core research loop only is the accepted MVP boundary; mock-bound surfaces move to v0.3+.
+36 -26
View File
@@ -1,46 +1,56 @@
# Personas — Milestone v0.1 (Prototype)
# Personas — Milestone v0.2 (MVP)
---
active:
- name: backend-engineer
- name: data-engineer
- name: frontend-engineer
- name: ci-lead-developer
phase_specific: []
reason: "v0.1 is a pure frontend prototype (Next.js, mock data). Only the frontend domain has work; backend/data personas activate in v0.2. Lead-developer coordinates decomposition and enforces the ApiClient boundary."
phase_specific:
- name: security-engineer
phases: [2]
reason: "v0.2 is the MVP: FastAPI backend + engine + adapters + trust loop (backend/data domains activate), the frontend consumes the OpenAPI-generated client in P3 (frontend stays active for the binding swap + P4 E2E), and the trust loop in P2 needs a phase-specific security-engineer for secrets handling and liveness verification. Lead-developer coordinates."
---
## Roster
### backend-engineer
- **Domain**: backend
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: fastapi, pydantic v2, uv, uvicorn, sse-starlette, httpx, pytest, pytest-asyncio
- **Constraints**: src-layout package `researchx` in backend/; all external I/O lives in adapters — engine imports no vendor SDKs (httpx only inside adapters); no blocking calls in async paths; durable state via atomic tmp+os.replace; never swallow CancelledError; API layer is transport only (no business logic); single uvicorn worker (single-user)
- **Territory**: `backend/**`
### data-engineer
- **Domain**: data
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: pydantic v2 (schemas), pytest
- **Constraints**: owns the pydantic schema contracts: dial schema (REQ-01), run state, evidence store models (stable passage IDs), citation ledger JSON schema (REQ-21); pure data + validation — no I/O in schema modules; every run persists resolved dial values (REQ-04)
- **Territory**: `backend/src/researchx/config/**`, `backend/src/researchx/evidence/**`
### frontend-engineer
- **Domain**: frontend
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: next.js (App Router), react 19, typescript (strict), tailwind, vitest
- **Constraints**: component-first; server components by default, client components only where interactivity demands; all data access through the ApiClient interface — components never import MockApiClient directly; no secrets or env keys in the frontend; typography care per vision §4
- **Frameworks**: next.js (App Router), react 19, typescript (strict), tailwind, vitest, openapi-fetch, openapi-typescript
- **Constraints**: components never import the mock client or the generated client directly — only the ApiClient boundary does; RESEARCHX_API_MODE=mock|live selects the binding, mock default; no secrets or env keys in the frontend (NEXT_PUBLIC_API_BASE_URL only); zero component rewrites in the P3 swap (REQ-54/REQ-26)
- **Territory**: `frontend/**`
### security-engineer (phase-specific: P2)
- **Domain**: security
- **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
- **Territory**: `backend/src/researchx/trust/**`, `backend/src/researchx/adapters/secrets*/**`
- **Removal**: deactivate after P2 ships
### ci-lead-developer
- **Domain**: coordination
- **Active**: true
- **Phase-specific**: false
- **Frameworks**: ciagent workflows, git discipline
- **Constraints**: enforce persona territories; ApiClient boundary review; commit discipline with ---ci--- blocks; phase-boundary file updates only (no .ciagent/ writes during task execution)
- **Territory**: `.ciagent/**`, repo root config files
### Deactivated (v0.1)
#### backend-engineer
- **Domain**: backend
- **Active**: false
- **Reason**: No backend in the prototype milestone; the FastAPI backend is milestone v0.2 (blocked until prototype approval)
- **Frameworks**: fastapi, pydantic, uv (activates in v0.2)
- **Territory**: `backend/**` (does not exist yet)
#### data-engineer
- **Domain**: data
- **Active**: false
- **Reason**: No database/schema work in v0.1; evidence store and citation ledger models arrive in v0.2; prototype uses typed fixtures
- **Frameworks**: pydantic schemas (activates in v0.2)
- **Territory**: `backend/src/researchx/evidence/**` (does not exist yet)
### Custom personas
None required for v0.1. The security-auditor persona activates in v0.2 phase 2 (trust loop: secrets handling, liveness verification). No CLI surface exists in this milestone, so no cli-engineer.
- **Constraints**: enforce persona territories; adapter/engine boundary review (I/O only in adapters); commit discipline with ---ci--- blocks; phase-boundary file updates only (no .ciagent/ writes during task execution); gates green before every ship
- **Territory**: `.ciagent/**`, repo root config files (README, VERSIONING, .gitignore)
+26 -46
View File
@@ -1,64 +1,44 @@
# PLAN — Milestone v0.1 Phase 3: v2 Surface Mockups + Polish + 3-Click Validation
# PLAN — Milestone v0.2 Phase 5 (FINAL): Review + Audit + Ship
---
phase: 3
milestone: v0.1
status: planned
phase: 5
milestone: v0.2
status: executed
must_haves:
- "Project graph mockup panel: cross-linked sources and findings across projects, rendered from mock data (REQ-60)"
- "Task inbox mockup panel: open questions become checkable follow-ups, rendered from mock data (REQ-60)"
- "Typography care pass: reader experience per vision §4 — report body measure/leading, section rhythm, small-caps/labels audit across all screens (REQ-61)"
- "≤3-click happy path validated on defaults: Ask → Choose → Run initiated in 3 clicks, verified by test (REQ-61)"
- "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:
- frontend-engineer
requirements: [REQ-60, REQ-61]
- ci-lead-developer
requirements: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-17, REQ-18, REQ-19, REQ-20, REQ-21, REQ-22, REQ-23, REQ-24, REQ-25, REQ-26, REQ-31, REQ-32, REQ-33, REQ-34]
---
## User-Facing Surface
- **`/library`** — the v2-surface mockup page: two panels rendered from fixture data — (1) **project graph**: nodes for research topics/projects with cross-links to shared sources and findings (visual mockup: SVG or CSS positioned nodes with labeled edges, clearly marked "v2 preview"); (2) **task inbox**: open questions extracted from past runs as checkable follow-up items with status toggles. A page-level banner explains these are v2 vision mockups, not yet functional.
- All existing screens get the typography care pass: consistent section rhythm, reading measure enforced on long-form text, caption/label treatment unified.
- Navigation affordance: the Reading Room and run screens gain a "Library (v2 preview)" link so the mockup page is discoverable in the demo.
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
## Happy Path (owner review script)
1. Owner runs the full P1+P2 path (ask → choose → approve → trace → report)
2. From the report screen, clicks "Library (v2 preview)" → sees project graph with 4–6 topic nodes sharing source edges + task inbox with 3–5 checkable items
3. Toggles a task's checkbox → item visibly flips status (client state only)
4. Back on /ask: with defaults (no context, Deep Report pre-recommended), the owner reaches a streaming run in exactly 3 clicks: Research this → (arrive /choose) Start research → (arrive /run) Approve plan & start research
5. The 3-click criterion is pinned by an automated test, not just manual belief
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
- Graph: nodes labeled with topic names; edges show shared-source counts; hover on node highlights it (title attribute acceptable); visually distinct from production screens ("v2 preview" banner + muted styling)
- Inbox: items show question text + originating run status; checkboxes toggle open↔done; at least one item pre-checked
- Typography: body text ≥16px, reading measure ≤68ch on report/ask/choose long-form, consistent heading scale, no clipped or truncated labels
- 3-click test: automated test counts discrete user clicks from /ask to a streaming run using default methodology — must be ≤3
- Quality gates: `pnpm typecheck && pnpm lint && pnpm test && pnpm build` green
- ≤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 (wave-ordered; persona: frontend-engineer unless noted)
## What this phase did
### Wave 1 — Fixtures
- **T-3.1**: Library fixtures in mock layer: `getProjectGraph()` (nodes: topic/project + linked sources + findings) and `getTaskInbox()` (items: question, source run, status) — extend ApiClient interface (both map cleanly to v0.2 GET /api/graph + GET /api/tasks) (REQ-60)
### Wave 2 — Library page
- **T-3.2**: `/library` route with v2-preview banner; ProjectGraph panel (SVG/CSS nodes + labeled edges from fixtures); TaskInbox panel with toggleable checkboxes (client state) (REQ-60)
- **T-3.3**: Navigation links: Reading Room + run screens gain "Library (v2 preview)" link; root nav hint if layout has one (REQ-60)
### Wave 3 — Typography pass
- **T-3.4**: Audit + fix typography across screens: reading measure on long-form, section rhythm (spacing-section usage), caption/label letter-spacing consistency, heading scale order (display → headline → body) (REQ-61)
### Wave 4 — 3-click validation
- **T-3.5**: Automated 3-click test: /ask → Research this (1) → /choose → Start research on recommended default (2) → /run → Approve plan (3) → assert trace streaming; document the count in the test name (REQ-61)
### Wave 5 — Gate (persona: ci-lead-developer)
- **T-3.6**: Boundary review + gate run; P1-flag fixes deferred to P4 are NOT pulled forward (P3 stays scoped)
## Verification
- Gates green; happy path walk passes; REQ-60, REQ-61 covered
- 3-click criterion pinned by test
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.
## Scope Guard
P3 only. The P4 final phase handles: review, audit, P1 flags (P1-1 drawer hyphen rendering from P1, P1-2 empty-question guard from P1, P2's routed P2 notes), owner approval gate, milestone merge. No v0.2 work.
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.
+10 -2
View File
@@ -51,5 +51,13 @@ Vision finalized and locked 2026-09-26: v1 methodology composition LOCKED (8 cor
- Model adapter default: Ollama cloud-first (ollama.com/v1) with keyless localhost:11434/v1 fallback on auth/connection failure
- Prototype-first gate (owner): UI/UX prototype with mock data, approved before MVP work; prototype is its own milestone (v0.1)
- Versioning (owner): starts at v0.1; MAJOR locked at 0 until owner-approved v1 release; MVP may span multiple 0.x minor milestones
- Prototype = production Next.js frontend with typed ApiClient/MockApiClient split — zero throwaway work; phase 4 of v0.2 swaps the mock for the OpenAPI client
- Tag-line mapping: 0.x milestones run patches on their own minor line (v0.1.x); supersedes the greenfield deviation noted at init
- Prototype = production Next.js frontend with typed ApiClient/MockApiClient split — zero throwaway work; phase 3 of v0.2 swaps the mock for the OpenAPI client
- Tag-line mapping: 0.x milestones run patches on their own minor line (v0.1.x); supersedes the greenfield deviation noted at init
## Key Decisions (v0.2 clarify — D-007..D-020, 2026-09-27)
- Backend layout: `backend/` repo-root package, src-layout `researchx` (config, methodologies, adapters, engine, evidence, trust, api per ARCHITECTURE §Components); uv + uv-managed Python 3.12 (D-007/D-008)
- Ports/binding: backend 127.0.0.1:8000, frontend :3000 via NEXT_PUBLIC_API_BASE_URL; CORS localhost:3000 (D-009); run IDs = 8-char slugs; storage = <dir>/runs/<id>/ (D-010)
- Adapters: Ollama base URLs + key via env-file secrets scopes (OLLAMA_BASE_URL, OLLAMA_API_KEY); DDG keyless fallback = html.duckduckgo.com/html conservative scrape (D-011/D-012)
- Integration: openapi-typescript + openapi-fetch → frontend/src/lib/api/generated, bound behind ApiClient; RESEARCHX_API_MODE=mock|live, mock default (D-013/D-020)
- Transport/trust: sse-starlette SSE + pydantic typed event bus (D-014); difflib quote anchoring ≥ 0.85 (D-015); HTTP liveness live/stale/dead (D-016); JSON durable state, atomic rewrite per transition (D-017)
- Run termination: pipeline ships P1..P4 + P5 final patch, then HALTS at the owner approval gate before milestone → main (merge_to_main hook, v0.1 precedent); no deploys without owner directive (D-018/D-019)
+28 -9
View File
@@ -17,7 +17,17 @@
### Vision Validation & Gate
- **REQ-60**: v2 surface mockups: project graph and task inbox panels rendered from mock data
- **REQ-61**: ≤3-click happy path on defaults (Ask → Choose → Run); typography care pass per vision §4
- **REQ-62**: Owner approval gate: prototype ships only after recorded owner approval (prototype_approval escalation hook); MVP milestone v0.2 begins only after v0.1.4 is tagged
- **REQ-62**: Owner approval gate: prototype ships only after recorded owner approval (prototype_approval escalation hook); MVP milestone v0.2 begins only after v0.1.4 is tagged — **APPROVED 2026-09-27: owner approval recorded on v0.1.8 (extended prototype); v0.2 unblocked**
### App Shell Extension (P5 — owner gate feedback, directed 2026-09-27)
- **REQ-63**: Persistent app shell on all screens: sidebar (primary nav + future-option slots) and top bar (search/command-palette slot + avatar button); shell adds zero required clicks to the ≤3-click happy path
- **REQ-64**: Research library: reports stored in a folder-structure view, auto-categorized (topic + methodology), sortable by recency/title; cross-report connection drawing — project graph links reports to each other (shared-source and topical edges)
- **REQ-65**: Settings surface behind the ApiClient boundary: API keys (model/search, masked), storage endpoint (local directory), methodology defaults (resolved dial display), appearance slot, about/version
- **REQ-66**: Avatar button (top bar) opening a user menu: local single-user profile (no auth), links to settings and about; future slots visible but honest (preview-marked)
### Ask Intelligence Extension (P7 — owner gate feedback, directed 2026-09-27)
- **REQ-67**: New Research view surfaces follow-up query suggestions derived from unanswered questions (open task-inbox items from past runs); one click loads a suggestion into the question box for editing — never auto-starts a run
- **REQ-68**: Refine option on the question: an opt-in back-and-forth dialogue (clarifying questions → answers → composed refined question + suggested context); accepting fills the form, or the original is kept
## MVP (Milestone v0.2 — core loop, gated on prototype approval)
@@ -63,6 +73,13 @@
- **REQ-33**: Monorepo: uv-managed backend (ruff, mypy, pytest) + Next.js frontend (ESLint, strict TS, Vitest); one-command install/dev
- **REQ-34**: Full export round-trip: report + ledger + citations survive as real links
### Clarified constraints (v0.2 P0 clarify — D-007..D-020)
- **D-007/D-008**: backend/ src-layout package `researchx` (config, methodologies, adapters, engine, evidence, trust, api); uv + managed Python 3.12
- **D-009/D-010**: backend 127.0.0.1:8000, CORS localhost:3000; run IDs 8-char slugs; runs/<id>/ storage layout
- **D-011/D-012**: model/search endpoints + keys resolve from env-file secrets scopes; DDG keyless fallback via html.duckduckgo.com/html scrape
- **D-013/D-020**: openapi-typescript + openapi-fetch generated client bound behind ApiClient; RESEARCHX_API_MODE=mock|live, mock default until P3
- **D-014..D-017**: sse-starlette SSE + pydantic event bus; difflib quote anchoring ≥ 0.85; HTTP liveness live/stale/dead; JSON durable state atomically rewritten per transition
## V2 (Post-MVP backlog)
### Methodology Catalog Extension
@@ -108,11 +125,13 @@
| REQ-55, REQ-56 (Ask, Choose) | v0.1 | P1 | complete |
| REQ-57 (Plan & Run) | v0.1 | P2 | complete |
| REQ-58, REQ-59 (Reading Room, exports) | v0.1 | P2 | complete |
| REQ-60, REQ-61 (v2 mockups, 3-click) | v0.1 | P3 | pending |
| REQ-62 (approval gate) | v0.1 | P4 | pending |
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | blocked |
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | blocked |
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | blocked |
| REQ-17, REQ-32, REQ-34 (adversarial, E2E, round-trip) | v0.2 | P4 | blocked |
| All REQ-01..34 (milestone gate) | v0.2 | P5 | blocked |
| REQ-35..51 | v2 backlog (post-MVP milestones) | — | pending |
| REQ-60, REQ-61 (v2 mockups, 3-click) | v0.1 | P3 | complete |
| REQ-62 (approval gate) | v0.1 | P8 | complete (owner approval recorded 2026-09-27) |
| REQ-63, REQ-64, REQ-65, REQ-66 (app shell, library, settings, avatar) | v0.1 | P5 | complete |
| REQ-67, REQ-68 (ask suggestions, refine dialogue) | v0.1 | P7 | complete |
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | complete (v0.2.1; REQ-07/12 contracts proven, live probing in P2) |
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | complete (v0.2.2) |
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | complete (v0.2.3) |
| REQ-17, REQ-32, REQ-34 (adversarial, E2E, round-trip) | v0.2 | P4 | complete (v0.2.4) |
| All REQ-01..34 (milestone gate) | v0.2 | P5 | complete (v0.2.5 — **owner approval recorded 2026-09-27**, G-009 ratified; milestone merged to main) |
| REQ-35..51 | v2 backlog (post-MVP milestones) | — | pending |
+140
View File
@@ -0,0 +1,140 @@
# Research — v0.2 MVP (Phase 0)
## Run: 2026-09-27 (stage: research)
Phase-0 research findings for milestone v0.2 (MVP): FastAPI backend, custom asyncio engine, adapter layer, trust loop, OpenAPI client integration. Each topic records concrete recommendations and pitfalls that PLAN consumes.
## 1. Backend foundation (uv + FastAPI)
**Recommendation:**
- uv (>= 0.4) with `backend/pyproject.toml`, src-layout: `backend/src/researchx/__init__.py`, `backend/src/researchx/{config,methodologies,adapters,engine,evidence,trust,api}/__init__.py`
- `[project] name = "researchx"`, `requires-python = ">=3.12"`; deps: `fastapi>=0.115,<0.116`, `pydantic>=2.8,<3`, `uvicorn>=0.30,<1`, `sse-starlette>=2.1,<3`, `httpx>=0.27,<1`
- Dev group (`uv sync --group dev` or `[dependency-groups]`): `ruff>=0.6`, `mypy>=1.11`, `pytest>=8`, `pytest-asyncio>=0.23`
- `[build-system] hatchling`; `[tool.hatch.build.targets.wheel] packages = ["src/researchx"]` — src-layout pitfall: without the explicit packages key, uv/hatch builds fail or ship an empty wheel
- Dev run: `cd backend && uv run uvicorn researchx.api.app:app --reload --port 8000`; one-command from root documented in README
- mypy strict on `src/`: `[tool.mypy] strict = true, mypy_path = "src"`; ruff: line-length 100, target py312
**Pitfalls:**
- `uv run` resolves the project venv automatically — never `pip install` into it
- pytest needs `pythonpath = ["src"]` in `[tool.pytest.ini_options]` or an editable install (`uv sync` does this) — tests import `researchx.*` directly
- pytest-asyncio: use `asyncio_mode = "auto"` and `loop_scope = "function"` (the old `event_loop` fixture is removed in recent versions)
## 2. Ollama OpenAI-compatible endpoints
**Recommendation:**
- Cloud: `https://ollama.com/v1` with `Authorization: Bearer <OLLAMA_API_KEY>`; local fallback `http://localhost:11434/v1` keyless
- Chat: POST `/chat/completions` (OpenAI shape: messages, model, temperature; response choices[0].message.content)
- Probe: GET `/models` → data[].id; capability check via a tiny structured-completion smoke test at connect time
- Context cap: `min(model_claim, 8192)` tokens; model claim unknown → 8192 flat; embedding batch 8
- Planner/writer roles = two model names from settings (default: same model both roles)
**Pitfalls:**
- ollama.com/v1 is cloud-keyed — 401 on missing/invalid key → automatic local-fallback attempt, then surfaced error
- Some served models don't support function calling/structured outputs → REQ-12 warning path: detect via smoke-test failure, warn in UI, degrade to plain chat + prompt-embedded JSON contract
- Model names are server-specific (`q4` suffixes etc.) — settings must store the exact served name; probe lists what's actually available
## 3. Search adapters
**Recommendation:**
- Tavily: POST `https://api.tavily.com/search`, JSON `{"api_key": key, "query": q, "search_depth": "basic"|"advanced", "max_results": n, "include_answer": false}` → `results[]: {title, url, content, score}`; timeouts 10s
- DuckDuckGo keyless fallback: POST `https://html.duckduckgo.com/html/?q=<urlencoded>` with a plain httpx client; parse anchors `a.result__a` (href → URL, text → title) and `a.result__snippet` from the result list; conservative: max 8 results, no retries beyond one backoff, user-agent string honest
- Adapter interface: `async search(query, max_results) -> list[SearchResult]`; registry selects Tavily when TAVILY_API_KEY present, else DDG
**Pitfalls:**
- DDG html rate-limits aggressively — the fallback is best-effort; on 403/timeout return partial/empty with a surfaced warning, never hang the engine
- Tavily's `content` is already a snippet — evidence extraction still fetches the live page (visited-only constraint requires the visit)
- Never put the API key in a query string or log line
## 4. FastAPI SSE + asyncio durability
**Recommendation:**
- `sse-starlette` EventSourceResponse fed by a per-run `asyncio.Queue`; pydantic event models with a discriminated union on `type` (run_started, query_fired, source_visited, passage_extracted, state_changed, run_done, run_error)
- EventSourceResponse generator: `async for event in queue` → `{"event": <type>, "data": <model_dump_json>}`; send on connect a `state_changed` replay of current persisted state
- Client disconnect: wrap the generator in try/finally that cancels only the SSE subscription (never the run); runs keep going when browsers disconnect
- Backpressure: unbounded queue per run is acceptable single-user; cap at 1000 with drop-oldest `state_changed` coalescing if needed
**Pitfalls:**
- No blocking I/O in the response generator — all fetches via httpx.AsyncClient
- uvicorn single worker; multiple workers would break in-memory run registry — document single-user explicitly
- On reconnect (Last-Event-ID unused in MVP) → replay current state + live events onward; full event log persisted in the run dir anyway
## 5. Durable state machine patterns
**Recommendation:**
- `runs/<id>/state.json`: `{run_id, state, dial_values, plan, question, created_at, updated_at, cursor}` — rewritten atomically after every transition: write `.tmp`, `os.replace`
- Transitions only at method boundaries of the state machine; the cursor records depth-loop position for resume
- Pause = asyncio.Event cleared; cancel = task.cancel() + terminal CANCELLED state persisted; resume on restart = state file read, EventBus rebuilt, loop re-entered at cursor
- pytest-asyncio: `asyncio_mode = "auto"`; tests construct the engine with fake adapters and `await engine.run()` directly
**Pitfalls:**
- Never swallow CancelledError — re-raise after persisting the cancel state
- Shield the state write: `with contextlib.suppress(asyncio.CancelledError): await asyncio.shield(save())` then re-raise
- Torn reads: only ever `os.replace` (atomic on same filesystem); never append — state is a snapshot, trace is a separate append-only file
## 5b. Testing fakes (REQ-31)
**Recommendation:**
- FakeModelAdapter: canned planner (sub-questions from a fixture), canned writer (deterministic report citing fixture passage IDs) — zero API keys
- FakeSearchAdapter: fixture corpus of 5-8 pages with overlapping passages; deterministic ordering
- Engine tests drive every transition, depth loop, pause/resume mid-depth-loop, cancel, and adversarial writer outputs
**Pitfalls:**
- Fakes must implement the full adapter interface (no stub methods) — verify layer asserts interface conformance
- Keep fixtures small and unicode-diverse (quote anchoring edge cases)
## 6. §6. Quote anchoring + liveness
**Recommendation:**
- Normalize (unicode NFC → casefold → whitespace collapse) both quote and passage; exact-substring short-circuit; else difflib SequenceMatcher ratio ≥ 0.85
- Ledger entry: `{url, visited_at, passages: [{id, quote}], claims: [claim_ids], health, authority_tier}`
- Liveness: single GET with redirect-follow (httpx, 10s timeout): 2xx live; 3xx loop/parked-domain or content-gone markers → stale; 4xx/5xx/timeout/unresolvable → dead
- ≥99% liveness gate: shipped report citations must pass liveness at export time; failures listed in the verification checklist
**Pitlingfalls:**
- SequenceMatcher on long passages is O(n²)-ish — anchor per-passage (passages are ≤ ~500 chars), not per-document
- Fuzzy matches near threshold (0.80-0.90) flagged in the ledger as `weak_match` for transparency
**Pitfalls:** (corrected)
- SequenceMatcher cost is quadratic-ish — always match quote vs its claimed passage only
- Near-threshold (0.80–0.90) matches flagged `weak_match` in the ledger — surfaced, not silently accepted
## 7. OpenAPI client generation
**Recommendation:**
- `openapi-typescript@^7` (types) + `openapi-fetch@^0.13` (typed fetch client), generated into `frontend/src/lib/api/generated/`
- Regen: `pnpm dlx openapi-typescript http://127.0.0.1:8000/openapi.json -o frontend/src/lib/api/generated/schema.d.ts` — needs the backend running, or read a committed `openapi.json` snapshot in CI
- Binding: a thin `GeneratedApiClient` implements the existing ApiClient interface by delegating to openapi-fetch with baseURL from NEXT_PUBLIC_API_BASE_URL; components never import it directly — only the boundary file does
- RESEARCHX_API_MODE=mock|live env flag selects the binding at boundary initialization; mock stays default
**Pitfalls:**
- FastAPI operation-id stability: route order and function names determine ids — fix function names early; regen discipline = regenerate only from a committed snapshot
- FastAPI's default unions (None | X) — openapi-typescript emits optional/nullable types that must be handled at the boundary, not in components
## 8. Monorepo ergonomics
**Recommendation:**
- Root README one-command story: `uv sync` (backend) + `corepack pnpm install` (frontend); dev: `uv run uvicorn ...` + `corepack pnpm dev`
- Backend gates: `uv run ruff check src tests`, `uv run mypy src`, `uv run pytest` (all via `uv run` from backend/)
- Frontend gates carry over from v0.1 unchanged: `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`
- Repo-root package.json is NOT created (frontend is the only JS package); document commands in README instead
**Pitfalls:**
- pnpm via corepack (packageManager field pins pnpm@12.6.0) — direct pnpm may be absent; always `corepack pnpm`
- openapi.json snapshot committed for reproducible client regen; regenerate the TS client when the snapshot changes
## Research Conclusions
1. Backend = uv + src-layout `researchx` in backend/, pyproject with hatchling `packages=["src/researchx"]`, deps fastapi/pydantic/uvicorn/sse-starlette/httpx, dev ruff/mypy/pytest/pytest-asyncio — one-command via `uv sync && uv run uvicorn researchx.api.app:app`.
2. All I/O lives in adapters; engine imports no vendor SDKs (httpx only inside adapters).
3. Ollama cloud `https://ollama.com/v1` (Bearer) → local `http://localhost:11434/v1` keyless fallback on 401/connection failure; probe via GET /models + structured-output smoke test; context cap min(claim, 8192), embed batch 8.
4. REQ-12 structured-output warning: detect via smoke test; degrade to prompt-embedded JSON contract + UI warning — never fail cryptically.
5. Tavily POST api.tavily.com/search with api_key in JSON body; DDG fallback scrapes html.duckduckgo.com/html anchors conservatively (max 8, one backoff, surfaced warnings on rate-limit).
6. SSE = sse-starlette EventSourceResponse + per-run asyncio.Queue + pydantic discriminated-union event types; state replay on connect; single uvicorn worker (single-user).
7. Durable state = runs/<id>/state.json, atomic tmp+os.replace per transition, cursor for depth-loop resume; trace is a separate append-only file; shielded state writes; never swallow CancelledError.
8. Quote anchoring = normalize + exact-substring short-circuit + SequenceMatcher ≥ 0.85 per-passage; near-threshold flagged weak_match.
9. Liveness = redirect-following GET, 10s: 2xx live / redirect-loop or content-gone stale / 4xx-5xx-timeout dead; ≥99% citation liveness gate at export.
10. Fake adapters (REQ-31) implement the full adapter interface with canned planner/writer + fixture corpus — enables the entire engine + trust test suite with zero keys.
11. Client gen = openapi-typescript@^7 + openapi-fetch@^0.13 from a committed openapi.json snapshot; GeneratedApiClient binds behind the ApiClient interface; RESEARCHX_API_MODE=mock|live selects binding; mock default.
12. Monorepo gates: backend `uv run ruff check`, `uv run mypy`, `uv run pytest`; frontend v0.1 gates unchanged; no root package.json — commands documented in README.
13. Config/status endpoints surface probe results + adapter status (REQ-25 GET /api/config) — plan the schema early so the settings surface stays honest.
14. All decisions D-007..D-020 confirmed workable; refinements: hatchling packages key (new), openapi.json snapshot discipline (new), single-worker uvicorn note (new), structured-output smoke-test detection pattern (new) — folded into PLAN as constraints.
+84 -22
View File
@@ -26,54 +26,116 @@ Two milestones, owner-directed 2026-09-27. **Milestone v0.1 — Prototype**: the
- **Depends on**: 1
- **Status**: complete (PR #2 squash-merged, tag v0.1.2, release created; P1-3/P1-4 review flags closed, four new P1s fixed in-phase)
### Phase 3 — v2 Surface Mockups + Polish + 3-Click Validation (next)
### Phase 3 — v2 Surface Mockups + Polish + 3-Click Validation (complete — shipped v0.1.3)
- **Description**: Project graph and task inbox panels as visual mockups (v2 vision validation); typography care pass (reader experience per vision §4); ≤3-click happy-path validation on defaults.
- **Requirements covered**: REQ-60, REQ-61
- **Success criteria**: graph + inbox render from mock data; 3-click path verified; typography pass done
- **Depends on**: 2
- **Status**: not_started
- **Status**: complete (PR #3 squash-merged, tag v0.1.3, release created; two P1 fixed in-phase)
### Phase 4 — Review + Owner Approval Gate + Ship (FINAL)
### Phase 4 — Review + Owner Approval Gate + Ship (shipped v0.1.4 — superseded by extension)
- **Description**: Review + audit, then the **owner approval gate**: prototype presented for owner review; approval recorded as a binding decision commit. Only after approval: milestone merge to main, tag v0.1.4 — the prototype release. (v0.2 P1 branches only after this tag.)
- **Requirements covered**: milestone gate (REQ-52..61 approved)
- **Success criteria**: owner approval recorded; v0.1.4 tagged; milestone branches deleted per ship workflow
- **Depends on**: 3
- **Status**: not_started
- **Status**: shipped (PR #4 squash-merged, tag v0.1.4 + release created; review 5 P0s fixed; audit PASS; owner returned gate feedback instead of approve/reject → binding extension cycle P5/P6 before the merge to main)
## Milestone v0.2 — MVP (feature) — BLOCKED until prototype approval
### Phase 5 — App Shell + Research Library + Settings + Avatar (complete — shipped v0.1.5)
- **Description**: Persistent app shell on every screen: sidebar (nav + future-option slots: connection status, methodology-defaults preview) + top bar (⌘K navigation palette slot + avatar button with user menu). Research library upgraded: folder-structure report storage with auto-categorization, sorting, and cross-report connection drawing in the project graph. Settings screen: API keys (masked), storage endpoint, methodology defaults, appearance slot, about/version. All data through the ApiClient boundary (mock-backed); zero added clicks to the ≤3-click happy path.
- **Requirements covered**: REQ-63, REQ-64, REQ-65, REQ-66
- **Success criteria**: shell renders on all screens with sidebar + top bar + avatar menu; library shows folder tree + sortable report cards + cross-report graph edges; settings sections all functional on mock data; 3-click path pinned green post-shell; gates green
- **Depends on**: 4
- **Status**: complete (PR #5 squash-merged to milestone/v0.1-prototype, tag v0.1.5, release created; verify PASS with one P1 fixed in-phase — main landmark restored)
Phases renumbered from the original v1.0 plan (1-based within the milestone):
### Phase 6 — Final Review + Approval Re-presentation + Ship (shipped v0.1.6 — superseded by 2nd extension)
- **Description**: Multi-persona review of the full milestone diff (now including P5), audit, then re-present the **extended prototype** at the REQ-62 owner approval gate. On recorded approval: milestone merge to main, tag v0.1.6 — the milestone release. (v0.2 P1 branches only after this tag.)
- **Requirements covered**: REQ-62 (gate) + milestone approval of REQ-52..66
- **Success criteria**: review PASS (P0s fixed in-phase); audit PASS; v0.1.6 tagged + release; pipeline halts at the approval gate for the owner's recorded decision
- **Depends on**: 5
- **Status**: shipped (PR #6 squash-merged, tag v0.1.6 + release created; review PASS-WITH-FIXES — deep-link P0 + 5 P1s fixed; audit PASS; owner directed UAT deploy then returned 2nd gate feedback → binding extension cycle P7/P8; milestone merge to main stays halted)
### Phase 7 — Ask Screen Intelligence: Suggestions + Refine (complete — shipped v0.1.7)
- **Description**: New Research view gains follow-up query suggestions derived from unanswered questions (open task-inbox items joined with their source reports); one click fills the question box — editable, never auto-starts. An opt-in Refine dialogue (inline drawer, one clarifying question at a time, scripted in the prototype) composes a refined question + suggested context; accept fills the form, or keep the original. Both are opt-in affordances — zero required clicks added to the ≤3-click happy path. All data through the ApiClient boundary.
- **Requirements covered**: REQ-67, REQ-68
- **Success criteria**: suggestions render from open inbox items with source attribution; click fills the box (no auto-submit); refine drawer completes a scripted exchange and composes a refined question; both flows keyboard-accessible; 3-click path pinned green; gates green
- **Depends on**: 6
- **Status**: complete (PR #7 squash-merged to milestone/v0.1-prototype, tag v0.1.7, release created; verify PASS — 2 P1 fixes with regression tests: Enter-guard defense in depth, focus return to trigger)
### Phase 8 — Final Review + Approval Re-presentation + Ship (FINAL — complete)
- **Description**: Multi-persona review of the full milestone diff (now including P7), audit, then re-present the extended prototype at the REQ-62 owner approval gate. On recorded approval: milestone merge to main, tag v0.1.8 — the milestone release. (v0.2 P1 branches only after this tag.)
- **Requirements covered**: REQ-62 (gate) + milestone approval of REQ-52..68
- **Success criteria**: review PASS (P0s fixed in-phase); audit PASS; v0.1.8 tagged + release; UAT staged per owner directive; pipeline halts at the approval gate
- **Depends on**: 7
- **Status**: complete (PR #8 squash-merged, tag v0.1.8 + release; review PASS-WITH-FIXES, audit PASS-WITH-NOTES; **owner approval RECORDED 2026-09-27** — decision commit 3fd0e10; milestone merged to main; v0.2 unblocked)
## Milestone v0.1 — COMPLETE (approved 2026-09-27)
All requirements complete: REQ-52..62, REQ-63..68. The approved
prototype release is **v0.1.8** (final phase patch). Milestone branch
merged to main and deleted — tags v0.1.0–v0.1.8 preserve the full
history. Next milestone: v0.2 MVP (unblocked).
## Milestone v0.2 — MVP (feature) — COMPLETE (approved 2026-09-27)
Phases renumbered from the original v1.0 plan (1-based within the milestone). Tags run on the v0.2.x line; MAJOR stays 0.
### Phase 0 — Pre-Execution (v0.2)
- **Description**: Milestone pre-execution re-run: PLAN.md refresh against approved prototype decisions (layout learnings, API shape implied by the ApiClient interface), research on any gaps surfaced by approval feedback.
- **Status**: blocked (gate: v0.1.4)
- **Description**: Milestone pre-execution: SPECIFY (spec validated for v0.2), CLARIFY (D-007..D-020: backend layout, uv+Python 3.12, ports/run-IDs, DDG fallback, openapi-typescript client, SSE+pydantic events, difflib anchoring, liveness rules, durable JSON state, owner-gate halt, no-deploy policy, RESEARCHX_API_MODE), RESEARCH (RESEARCH-v02.md — 8 topics, 14 conclusions, 4 D-refinements), PLAN (P1 vertical-slice plan), GRILL (mechanical nine-axis review), MVP/UX check.
- **Requirements covered**: none (planning artifacts)
- **Success criteria**: .ciagent/ files refreshed for v0.2; PLAN.md carries User-Facing Surface + Happy Path + UX Acceptance Criteria; PERSONAS.md v0.2 roster active; grill verdict recorded with binding decisions
- **Depends on**: v0.1 approval (recorded)
- **Status**: complete (PR #9 squash-merged to milestone/v0.2-mvp bbf80ce, tag v0.2.0, release created HTTP 201, phase branch deleted; grill Proceed 0.72 with G-001..G-010 promoted; MVP/UX PASS)
### Phase 1 — Methodology Framework + Adapter Layer
- **Description**: Dial schema (12 dials, typed, safe defaults), methodology registry with Rapid Answer + Deep Report, adapter contracts (model/search/storage/memory/secrets) with local implementations, capability probing with conservative caps (8192 context, embedding batch 8, Ollama cloud-first + local fallback), fake model/search adapters.
- **Requirements covered**: REQ-01..06, REQ-08..11, REQ-31
- **Status**: blocked
- **Description**: Dial schema (12 dials, typed, safe defaults), methodology registry with Rapid Answer + Deep Report, adapter contracts (model/search/storage/memory/secrets) with local implementations, capability probing with conservative caps (8192 context, embedding batch 8, Ollama cloud-first + local fallback), fake model/search adapters. Full task waves in PLAN.md.
- **Requirements covered**: REQ-01..06, REQ-08..11, REQ-31 (REQ-07, REQ-12 ship in P2 per ROADMAP phase mapping — dial-level + warning contracts are proven here, live probing against real endpoints in P2)
- **Success criteria**: uv one-command backend; gates green zero-API-key; registry resolves slugs; dial snapshot round-trips; fakes drive full interface; degradation typed + honest
- **Depends on**: 0
- **Status**: complete (PR #10 squash-merged to milestone/v0.2-mvp dfb738d, tag v0.2.1, release HTTP 201, phase branch deleted; verify PASS 0 P0 / 2 P1 fixed in-phase / 9 P2 flagged; REQ-07/12 contracts proven with fakes per G-001, live probing in P2)
### 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
- **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
- **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
- **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
- **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 = approved prototype release** (feature milestone; final phase patch)
- **v0.2.x = MVP release** (MAJOR still 0; ship only if all v0.2 phases complete)
- **v0.1.4 = extended-prototype candidate** (superseded by the P5/P6 extension cycle)
- **v0.1.5 = app shell + research library + settings + avatar** (P5 patch)
- **v0.1.6 = extended-prototype candidate** (superseded by the P7/P8 extension cycle)
- **v0.1.7 = ask suggestions + refine dialogue** (P7 patch)
- **v0.1.8 = milestone release** (P8 final phase patch — the approved extended prototype release, pending owner decision)
- **v0.2.5 = milestone release** (P5 final phase patch — the approved MVP; owner approval recorded 2026-09-27, G-009 ratified)
- 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
- **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.1",
"milestone_name": "prototype",
"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.1.x",
"next_milestone": "v0.2-mvp (blocked until prototype approval)"
"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"
]
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
# Deploy Record — UAT stagings and releases
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 |
|---|---|---|---|---|
| 2026-09-27 | v0.1.4-uat | d15568f (P3 ship) | owner review staging (P4) | healthy — http://10.0.0.114:3000 |
| 2026-09-27 | v0.1.6-uat | 600b49c (P6 ship) | owner: "push updates to lxc container" | healthy — http://10.0.0.114:3000 |
| 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)
- Gates re-run at deploy time from the milestone HEAD: typecheck, lint,
test (75/75), build — all green before sync
- Image `researchx:v0.1.6-uat` built on the host; container swapped with
`researchx-prev` kept for rollback
- Health check passed (2 retries); smoke verified: /ask /library
/settings all 200; shell chrome (sidebar nav, ⌘K slot, avatar) present
in server-rendered HTML
- Rollback: `ssh root@10.0.0.114 'docker stop researchx; docker rename
researchx-prev researchx; docker start researchx'`
- The approval gate remains closed: milestone merge to main still halts
for the recorded owner decision (REQ-62)
## v0.2.5-uat (MVP release candidate, owner-directed)
- Deployed via `bash .ciagent/release/deploy.sh v0.2.5-uat` — the v0.2
milestone HEAD (c28a97e: tag v0.2.5 + v0.2.5 About/package.json
version strings), per the owner's direct request to deploy and
validate against the public URL
- Gates re-run at deploy time from the milestone HEAD: typecheck,
lint, test (97/97), build — green before sync
- Image `researchx:v0.2.5-uat` + `latest` on the host; container
swapped, `researchx-prev` (v0.1.8) kept for rollback
- Health: 2 checks; validated through the HAProxy SSL front:
https://research.coreci.dev — /ask /library /settings /choose all
200, root 307→/ask, TLS verified; served settings chunk carries
"v0.2.5 · MVP release candidate"; the RESEARCHX_API_MODE switch is
present in the served bundle (mock default — correct for a
frontend-only container; live mode additionally needs the backend
API on the host, which is v0.3+ deployment work)
- The merge gate (D-018) remains closed: milestone → main still halts
for the recorded owner decision, with G-009 (live-mode scope)
presented at the same gate
+58
View File
@@ -0,0 +1,58 @@
# CIAgent Release System — Docker Deployment
Codified release workflow: builds the ResearchX frontend as a
production Next.js standalone image and deploys it to the Docker host
via SSH. Designed for repeatable, parameterized future releases.
## Components
| File | Purpose |
|---|---|
| `frontend/Dockerfile` | Multi-stage build: pnpm install → build → distroless-standalone runtime (node:22-alpine) |
| `.ciagent/release/deploy.sh` | One-command release: sync source → remote build → run container. Idempotent |
| `.ciagent/release/config.sh` | Non-secret parameters (host, port, image name). Secrets come ONLY from `.ciagent/.env.secrets` |
| `.ciagent/release/VERSIONING.md` | Release/version rules for future releases |
## Secrets discipline
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_PASSWORD` live ONLY in
`.ciagent/.env.secrets` (0600, gitignored). The deploy script reads
them from there — never from shell environment, never committed.
Non-secret host parameters (ports, paths) live in `config.sh`.
## Usage
```bash
bash .ciagent/release/deploy.sh <version>
```
Examples:
```bash
bash .ciagent/release/deploy.sh v0.1.5 # deploy tagged version
bash .ciagent/release/deploy.sh v0.2.1 # future releases
```
## What a release does
1. Validate version tag exists locally (or fetch from origin)
2. Verify gates: `pnpm typecheck && pnpm lint && pnpm test && pnpm build`
3. Export a build bundle of the frontend source at that tag
4. SSH (via `sshpass`) to the Docker host with credentials from `.env.secrets`
5. `rsync` the bundle up; remote `docker build` the standalone image
6. Stop any previous `researchx` container; run the new one on the configured port
7. Health-check the container; report the URL
## Rollback
```bash
ssh <host> docker stop researchx && docker start researchx-prev
```
The previous container is kept as `researchx-prev` (renamed on each
release) for one-step rollback.
## Versioning rules
See `VERSIONING.md`. Tags follow the milestone patch line (v0.1.x for
milestone v0.1). The `latest` tag may also be pushed for convenience
but is never the source of truth.
+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)
+52
View File
@@ -0,0 +1,52 @@
# Release Versioning — ResearchX
## Tags are the release source of truth
Every release deploys a **git tag**, never a branch head. Tags follow
the milestone patch line (v0.1.x for milestone v0.1, v0.2.x for v0.2),
as established by the CIAgent branch strategy and `config.json`.
| Milestone | Tags | Deployed by |
|---|---|---|
| v0.1 prototype | v0.1.0 … v0.1.8 | `bash .ciagent/release/deploy.sh v0.1.8` |
| v0.2 MVP | v0.2.0 … v0.2.5 | `bash .ciagent/release/deploy.sh v0.2.5-uat` (staged) — see DEPLOY-RECORD.md |
## Rules
1. **A release = a tag.** No tag, no deploy. The script refuses
untagged versions.
2. **Gates run at the tag.** typecheck, lint, test, and build all run
against the tagged tree (in a temporary worktree if HEAD has moved
past the tag) — the same ship-workflow rule that no failing code
ships, applied to deployment.
3. **Rolling, idempotent swap.** Re-deploying a version replaces the
running container. The previous container is preserved as
`researchx-prev` for one-step rollback. Two consecutive deploys of
the same version keep the older one as rollback.
4. **Image tags.** Each deploy tags the image with the version AND
`latest`. The version tag is authoritative; `latest` is a pointer
of convenience only.
5. **Secrets never in git.** `DEPLOY_HOST`, `DEPLOY_USER`,
`DEPLOY_PASSWORD` live only in `.ciagent/.env.secrets` (0600,
gitignored). Host/port/path parameters are non-secret and live in
`deploy.sh`'s parameter block.
6. **Milestone gating applies to deployment too.** The v0.1 prototype
deploys for owner review. v0.2 releases deploy only after the
REQ-62 owner-approval gate is cleared (recorded on the extended
prototype, v0.1.7 → released as v0.1.8).
## Container conventions
- Image: `researchx:<version>`, container: `researchx`, rollback:
`researchx-prev`, host port: 3000, remote tree: `/opt/researchx/releases/<version>`.
- The container is `--restart unless-stopped`; a host reboot brings
the release back automatically.
## Adding a future release
```bash
# after a phase ships its tag:
bash .ciagent/release/deploy.sh v0.2.0
```
No script changes needed — parameters are at the top of `deploy.sh`.
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env bash
# ResearchX release deploy (ciagent release system)
#
# Builds the frontend at a given version tag on the Docker host and
# runs it as a container. Idempotent: re-deploying a version replaces
# the running container, keeping the previous one as rollback.
#
# Usage: bash .ciagent/release/deploy.sh <version> e.g. v0.1.4
#
# Secrets are read ONLY from .ciagent/.env.secrets:
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_PASSWORD
# Never from the shell environment, never committed (v1.8 rule).
#
# Exit codes: 0 success · 1 usage · 2 missing deps/tag/secrets ·
# 3 gates failed · 4 sync/build failed · 5 run/health failed
set -euo pipefail
# ---- parameters (non-secret) ------------------------------------------
IMAGE_NAME="researchx"
CONTAINER_NAME="researchx"
CONTAINER_PREV="researchx-prev"
HOST_PORT="3000"
REMOTE_BASE="/opt/researchx"
HEALTH_PATH="/ask"
HEALTH_RETRIES=15
HEALTH_INTERVAL=2
# -----------------------------------------------------------------------
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SECRETS_FILE="$REPO_ROOT/.ciagent/.env.secrets"
log() { printf '[release] %s\n' "$*"; }
die() { printf '[release] ERROR: %s\n' "$*" >&2; exit "$2"; }
[ "$#" -eq 1 ] || die "usage: bash .ciagent/release/deploy.sh <version> (e.g. v0.1.4)" 1
VERSION="$1"
# ---- dependency checks --------------------------------------------------
command -v ssh >/dev/null || die "ssh not found" 2
# ---- secrets (env file ONLY) -------------------------------------------
[ -f "$SECRETS_FILE" ] || die "secrets file missing: $SECRETS_FILE" 2
DEPLOY_HOST=$(grep -E "^DEPLOY_HOST=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
DEPLOY_USER=$(grep -E "^DEPLOY_USER=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
DEPLOY_PASSWORD=$(grep -E "^DEPLOY_PASSWORD=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
DEPLOY_KEY=$(grep -E "^DEPLOY_KEY=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
[ -n "$DEPLOY_HOST" ] && [ -n "$DEPLOY_USER" ] \
|| die "DEPLOY_HOST/DEPLOY_USER not set in $SECRETS_FILE" 2
# Auth: an SSH key (DEPLOY_KEY=path in .env.secrets) is preferred —
# it survives sshd 'PermitRootLogin prohibit-password' policies that
# reject root passwords. Password auth (DEPLOY_PASSWORD) is fallback.
SSH_AUTH_DIR=$(mktemp -d)
trap 'rm -rf "$SSH_AUTH_DIR"' EXIT
if [ -n "$DEPLOY_KEY" ] && [ -f "$DEPLOY_KEY" ]; then
SSH_TRANSPORT="ssh-key:${DEPLOY_KEY##*/}"
SSH() { ssh -i "$DEPLOY_KEY" -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"; }
else
[ -n "$DEPLOY_PASSWORD" ] \
|| die "neither DEPLOY_KEY (existing file) nor DEPLOY_PASSWORD set in $SECRETS_FILE" 2
if command -v sshpass >/dev/null; then
SSH_TRANSPORT="sshpass"
SSH() { sshpass -p "$DEPLOY_PASSWORD" ssh -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"; }
else
SSH_TRANSPORT="ssh-askpass"
cat > "$SSH_AUTH_DIR/askpass.sh" <<ASKPASS
#!/usr/bin/env bash
printf '%s\n' "\$DEPLOY_PASSWORD"
ASKPASS
chmod 700 "$SSH_AUTH_DIR/askpass.sh"
SSH() {
DEPLOY_PASSWORD="$DEPLOY_PASSWORD" SSH_ASKPASS="$SSH_AUTH_DIR/askpass.sh" \
SSH_ASKPASS_REQUIRE=force DISPLAY=:0 \
ssh -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"
}
fi
fi
log "ssh transport: $SSH_TRANSPORT"
if command -v rsync >/dev/null; then
SYNC_TRANSPORT="rsync"
else
SYNC_TRANSPORT="tar-ssh"
fi
# Sync the build context to the host (excludes live in the Dockerfile's
# .dockerignore; we only prune heavy dirs here). rsync uses the SSH
# function's transport implicitly via -e; tar-over-ssh uses SSH directly.
sync_source() {
local src="$1" dst="$2"
if [ "$SYNC_TRANSPORT" = "rsync" ]; then
if [[ "$SSH_TRANSPORT" == ssh-key:* ]]; then
rsync -az --delete --exclude node_modules --exclude .next --exclude .git \
-e "ssh -i '$DEPLOY_KEY' -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
"$src" "$DEPLOY_USER@$DEPLOY_HOST:$dst/"
elif [ "$SSH_TRANSPORT" = "sshpass" ]; then
sshpass -p "$DEPLOY_PASSWORD" rsync -az --delete \
--exclude node_modules --exclude .next --exclude .git \
-e "ssh -o StrictHostKeyChecking=accept-new" "$src" "$DEPLOY_USER@$DEPLOY_HOST:$dst/"
else
DEPLOY_PASSWORD="$DEPLOY_PASSWORD" SSH_ASKPASS="$SSH_AUTH_DIR/askpass.sh" \
SSH_ASKPASS_REQUIRE=force DISPLAY=:0 \
rsync -az --delete --exclude node_modules --exclude .next --exclude .git \
-e "ssh -o StrictHostKeyChecking=accept-new" "$src" "$DEPLOY_USER@$DEPLOY_HOST:$dst/"
fi
else
# tar-over-ssh: pipe a pruned tarball through the same SSH auth path
(cd "$src" && tar --exclude=node_modules --exclude=.next --exclude=.git -cf - .) \
| SSH "mkdir -p '$dst' && rm -rf '$dst'/* && tar -xf - -C '$dst'"
fi
}
# ---- tag validation ------------------------------------------------------
log "validating version $VERSION"
git -C "$REPO_ROOT" fetch origin --tags --prune 2>/dev/null || true
# A plain tag (v0.1.4) deploys exactly that commit. A "<tag>-uat"
# variant (v0.1.4-uat) deploys the milestone HEAD that tag lives on —
# used to stage review infra (e.g. the Dockerfile the tag predates)
# without minting a new release tag. UAT builds are clearly labeled.
UAT_BUILD=false
BASE_VERSION="$VERSION"
if [[ "$VERSION" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+)-uat$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
UAT_BUILD=true
fi
git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$BASE_VERSION" >/dev/null \
|| die "tag $BASE_VERSION not found locally or on origin" 2
if [ "$UAT_BUILD" = true ]; then
# Deploy the milestone branch HEAD carrying this tag (review staging).
# Branch labels use the MILESTONE number (v0.1), tags the full patch
# version (v0.1.4) — strip the patch segment for the branch lookup.
MILESTONE_VER="${BASE_VERSION%.*}" # v0.1.4 -> v0.1
MILESTONE_BRANCH=$(git -C "$REPO_ROOT" branch -r --list "origin/milestone/${MILESTONE_VER}-*" | head -1 | tr -d ' ')
[ -n "$MILESTONE_BRANCH" ] || die "no milestone branch found for $BASE_VERSION (looked for origin/milestone/${MILESTONE_VER}-*)" 2
TAG_COMMIT=$(git -C "$REPO_ROOT" rev-parse "${MILESTONE_BRANCH#origin/}" 2>/dev/null || git -C "$REPO_ROOT" rev-parse "$MILESTONE_BRANCH")
log "UAT build: milestone HEAD $TAG_COMMIT (tag $BASE_VERSION + review infra)"
else
TAG_COMMIT=$(git -C "$REPO_ROOT" rev-list -n1 "$BASE_VERSION")
log "tag $BASE_VERSION -> commit $TAG_COMMIT"
fi
# ---- gates (ship rule: never release failing code) ----------------------
log "running release gates at $VERSION"
export PATH="$HOME/.corepack/bin:$PATH"
cd "$REPO_ROOT/frontend"
git -C "$REPO_ROOT" worktree list >/dev/null 2>&1 || true
if [ "$(git -C "$REPO_ROOT" rev-parse HEAD)" != "$TAG_COMMIT" ]; then
# Build from the tagged tree in a temporary worktree — HEAD may be ahead.
log "HEAD differs from tag; building from a temporary worktree at $VERSION"
WORKTREE=$(mktemp -d)
git -C "$REPO_ROOT" worktree add --detach "$WORKTREE" "$TAG_COMMIT" >/dev/null
trap 'git -C "$REPO_ROOT" worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
BUILD_DIR="$WORKTREE/frontend"
else
BUILD_DIR="$REPO_ROOT/frontend"
fi
(cd "$BUILD_DIR" && pnpm install --frozen-lockfile >/dev/null 2>&1)
(cd "$BUILD_DIR" && pnpm typecheck >/dev/null) || die "typecheck failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm lint >/dev/null) || die "lint failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm test >/dev/null 2>&1) || die "tests failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm build >/dev/null 2>&1) || die "build failed at $VERSION" 3
log "gates passed: typecheck, lint, test, build"
cd "$REPO_ROOT"
# ---- remote host prep ----------------------------------------------------
log "connecting to $DEPLOY_USER@${DEPLOY_HOST%%:*} and preparing $REMOTE_BASE"
SSH "mkdir -p '$REMOTE_BASE/releases/$BASE_VERSION'" || die "cannot reach host or create remote dir" 4
SSH "command -v docker >/dev/null" || die "docker not found on host" 4
# ---- sync source ---------------------------------------------------------
log "syncing source to host ($REMOTE_BASE/releases/$BASE_VERSION) via $SYNC_TRANSPORT"
sync_source "$BUILD_DIR" "$REMOTE_BASE/releases/$BASE_VERSION" \
|| die "source sync failed" 4
# ---- remote build --------------------------------------------------------
log "building docker image $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
SSH "cd '$REMOTE_BASE/releases/$BASE_VERSION' && \
docker build -t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|| die "docker build failed on host" 4
# ---- swap container ------------------------------------------------------
log "swapping container (previous kept as $CONTAINER_PREV for rollback)"
SSH "
docker rm -f '$CONTAINER_PREV' 2>/dev/null || true
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_NAME'; then
docker rename '$CONTAINER_NAME' '$CONTAINER_PREV'
docker stop '$CONTAINER_PREV' >/dev/null 2>&1 || true
fi
docker run -d --name '$CONTAINER_NAME' --restart unless-stopped \
-p '$HOST_PORT:3000' '$IMAGE_NAME:$VERSION'
" || die "docker run failed" 5
# ---- health check ---------------------------------------------------------
log "health-checking http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
i=0
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT$HEALTH_PATH'"; then
log "healthy after $((i + 1)) check(s)"
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (container $CONTAINER_NAME)"
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker start $CONTAINER_NAME'"
exit 0
fi
i=$((i + 1))
sleep "$HEALTH_INTERVAL"
done
die "container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
+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
+98 -7
View File
@@ -1,28 +1,119 @@
# 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: prototype (milestone v0.1)** — UI/UX with mock data. No backend, no API keys, no network calls.
**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
```bash
# frontend (prototype, mock-backed — works today)
cd frontend
pnpm install
pnpm dev
corepack pnpm install
corepack pnpm dev # http://localhost:3000
# backend (MVP — uv manages Python 3.12 and the venv)
cd backend
uv sync # installs deps + editable researchx package
uv run uvicorn researchx.api.app:app --reload --port 8000
uv run pytest # full suite, zero API keys
```
Open http://localhost:3000 — you land on **Ask**. Type a question, click **Research this**, pick a methodology on **Choose** (Deep Report is the recommended default), and press **Start research**.
Three clicks from cold start to initiated research.
## Monorepo command map
| Layer | Install | Dev | Gates |
|---|---|---|---|
| backend (uv, Python 3.12) | `uv sync` | `uv run uvicorn researchx.api.app:app --reload --port 8000` | `uv run ruff check src tests` · `uv run mypy src` · `uv run pytest` |
| frontend (pnpm 12.6) | `corepack pnpm install` | `corepack pnpm dev` | `corepack pnpm typecheck` · `corepack pnpm lint` · `corepack pnpm test` · `corepack pnpm build` |
## Engine architecture (v0.2 P2)
The research engine is a hand-rolled asyncio state machine:
```
PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING ─┐
(halt until ↑ depth loop (dials.depth) │
approved) └────────────────────────────┘
→ SYNTHESIZING → VERIFYING → DONE (+ CANCELLED / FAILED terminals)
```
- Durable state: `runs/<id>/state.json` rewritten atomically after every transition; resume re-enters at the recorded cursor with the persisted plan
- Event bus: typed events (`run_started`, `query_fired`, `source_visited`, `passage_extracted`, `state_changed`, `run_done`, `run_error`) — trace per run
- Visited-only constraint: the writer's context is the evidence store and nothing else
- Trust loop: SSRF-guarded liveness (live/stale/dead), quote anchoring (normalize + fuzzy ≥ 0.85, weak matches flagged), citation ledger JSON, exportable verification checklist, single-source vs corroborated confidence labels
Run artifacts per run: `state.json`, `report.md`, `ledger.json`, `checklist.md`.
Try it on fakes (no keys): `uv run python -m researchx.engine.demo`
## Adversarial guarantees (v0.2 P4)
Every trust-loop claim is attacked by the adversarial suite (`backend/tests/test_adversarial.py`) — these are tests that try to fool the verifier, and their passing proves the defenses:
| Attack | Defense (proven) |
|---|---|
| Writer hallucinates a citation to a passage that was never stored | Unknown passage IDs count as **unanchored claims** in the ledger; the export gate fails the report |
| Fabricated quote (matches nothing) | Anchoring verdict: no match, flagged — never silently accepted |
| Near-miss quote (0.80–0.85 similarity) | Flagged `weak_match` in the ledger — surfaced, not accepted |
| Liveness probe redirected to a private/metadata IP | Per-hop redirect re-guarding classifies it **dead** (SSRF refusal) |
| Redirect chain ending at a gone page (410) | Classified **dead** |
| Oversized page (5MB) response | 2MB read cap — classifies without unbounded memory |
| Pause during the depth loop | Durable state persists; resume completes the run over the HTTP API |
| Cancel mid-loop | Terminal CANCELLED state persisted, never swallowed |
REQ-23 (≥99% citation liveness) is a per-run **export gate** enforced by the verification checklist, plus an opt-in keyed live tier (`cd backend && uv run pytest -m live`) — the zero-key CI suite never asserts it vacuously.
The E2E happy path (≤3 clicks: ask, choose, approve) is validated against the real FastAPI + engine stack, defaults only — not the mock (`frontend/src/lib/api/e2e-real-stack.test.ts`).
## Adapter configuration (v0.2)
All keys resolve from env files only (`.env`, `.env.secrets`, `.env.*` — never the shell environment, never git):
| Variable | Scope | Used by |
|---|---|---|
| `OLLAMA_API_KEY` | model (cloud) | Ollama cloud endpoint `https://ollama.com/v1`; absent/keyless falls back to `http://localhost:11434/v1` automatically |
| `OLLAMA_BASE_URL` | model | Overrides the default cloud base URL |
| `TAVILY_API_KEY` | search | Tavily default search; absent falls back to the keyless DuckDuckGo adapter (degraded mode — surfaced in the run trace) |
The test suite needs **no** keys: fake model/search adapters (REQ-31) drive every engine test.
## Live mode (v0.2 P3)
The frontend runs mock-backed by default (works standalone, zero keys). To run the core research loop against the real backend:
```bash
# terminal 1 — backend (fake adapters by default; real Ollama/Tavily adapters
# are used by the engine in v0.2.5+ wiring — the served API currently ships
# fake-adapter runs for demos and tests; see Adapter configuration below)
cd backend && uv run uvicorn researchx.api.app:app --reload --port 8000
# terminal 2 — frontend in live mode
cd frontend && NEXT_PUBLIC_RESEARCHX_API_MODE=live corepack pnpm dev
```
Live mode covers the core loop (Ask → Choose → approve plan → live trace → report + ledger); the library, settings persistence, suggestions, refine, and graph surfaces stay mock-backed until v0.3. The API surface: `POST /api/runs`, `GET /api/methodologies`, run lifecycle (`approve/pause/cancel/resume`), `GET /api/runs/{id}/events` (SSE), `GET /api/config`, report + ledger artifacts.
## What's in the prototype
| Screen | What it does |
|---|---|
| `/ask` | One question box + one optional context box. Nothing else competing for attention. |
| `/ask` | One question box + one optional context box — plus two opt-in helpers below and beside them: open-question suggestions (follow-ups from your past runs, one click fills the box) and the Refine question dialogue (a few clarifying answers → a composed, sharper question). Neither ever auto-starts. |
| `/choose` | All 8 locked methodology cards — mock ETA, cost estimate, why-recommended, and the 12 methodology dials behind each card's Advanced drawer. |
| `/run/[id]` | Placeholder — Plan & Run ships in phase 2. |
| `/run/[id]` | Plan & Run: approve the research plan (sub-questions, source universe, outline), watch the live trace stream, pause/resume or cancel. |
| `/run/[id]/report` | Reading Room: the report with page-anchored citations — hover or focus any `[n]` marker for the exact quoted passage — plus the citation ledger and Markdown/JSON exports. |
| `/library` | Stored reports filed into auto-categorized folders (sortable, links into the Reading Room), the Connections panel (shared sources ⇄, related topics →), plus v2-preview project graph and task inbox. |
| `/settings` | API-key status (masked, never raw), the reports storage directory, methodology defaults with the resolved 12 dials, appearance, and about/version. |
The persistent shell (every screen): sidebar nav + adapter connection-status dots, top bar with the ⌘K navigation palette (screens + stored reports) and the avatar menu (local profile, Settings, About).
All data flows through a typed `ApiClient` interface backed by `MockApiClient` — the same components will talk to the FastAPI backend via the OpenAPI-generated client in the MVP milestone, with zero rewrites.
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
+1
View File
@@ -0,0 +1 @@
3.12
+48
View File
@@ -0,0 +1,48 @@
[project]
name = "researchx"
version = "0.2.0"
description = "ResearchX — methodology-first deep research studio"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115,<0.116",
"pydantic>=2.8,<3",
"uvicorn>=0.30,<1",
"sse-starlette>=2.1,<3",
"httpx>=0.27,<1",
]
[dependency-groups]
dev = [
"ruff>=0.6",
"mypy>=1.11",
"pytest>=8",
"pytest-asyncio>=0.23",
"httpx>=0.28.1",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/researchx"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
extend-select = ["I", "UP"]
[tool.mypy]
strict = true
mypy_path = "src"
python_version = "3.12"
[tool.pytest.ini_options]
pythonpath = ["src"]
markers = [
"live: opt-in keyed tests against real adapters (skipped without keys)",
]
asyncio_mode = "auto"
testpaths = ["tests"]
View File
@@ -0,0 +1,52 @@
import json
import httpx
from researchx.adapters.model import ModelAdapter
from researchx.adapters.probe import (
DEFAULT_EMBEDDING_BATCH,
CapabilityProbe,
cap_context_window,
structured_output_warning,
)
from researchx.adapters.types import ModelMessage
def supports_probe(adapter: ModelAdapter) -> bool:
return getattr(adapter, "probing_supported", False)
async def probe_model_adapter(adapter: ModelAdapter) -> CapabilityProbe:
models = await adapter.list_models()
smoke_ok = await _structured_output_smoke_test(adapter)
writer = adapter.roles().writer
warning = structured_output_warning(smoke_ok, writer)
endpoint = getattr(adapter, "active_base_url", "unknown")
fallback_reason = getattr(adapter, "last_fallback_reason", None)
return CapabilityProbe(
endpoint=str(endpoint),
models=models,
context_window=cap_context_window(None),
embedding_batch=DEFAULT_EMBEDDING_BATCH,
structured_outputs_supported=smoke_ok,
structured_output_warning=warning,
fallback_reason=fallback_reason,
)
async def _structured_output_smoke_test(adapter: ModelAdapter) -> bool:
probe_messages = [
ModelMessage(
role="user",
content='Return a JSON object with exactly one key "ok" set to true. No other text.',
)
]
try:
result = await adapter.chat(probe_messages, model=adapter.roles().writer, json_mode=True)
except (httpx.HTTPError, httpx.HTTPStatusError, ValueError, KeyError):
return False
try:
parsed = json.loads(result.content.strip())
except json.JSONDecodeError:
return False
return isinstance(parsed, dict) and parsed.get("ok") is True
@@ -0,0 +1,136 @@
import re
from datetime import UTC, datetime
from html import unescape
from urllib.parse import parse_qs, unquote, urlparse
import httpx
from researchx.adapters.secrets import SecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter, extract_text, extract_title
from researchx.adapters.types import AdapterError, PageContent, SearchResult
from researchx.adapters.url_guard import url_allowed
_DDG_URL = "https://html.duckduckgo.com/html"
_SEARCH_TIMEOUT = 10.0
_FETCH_TIMEOUT = 10.0
_MAX_FETCH_REDIRECTS = 5
_BACKOFF_SECONDS = 1.0
_USER_AGENT = "researchx/0.2 (local research studio)"
_RESULT_RE = re.compile(
r'<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
re.DOTALL,
)
_SNIPPET_RE = re.compile(r'<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
_TAG_RE = re.compile(r"<[^>]+>")
class DuckDuckGoSearchAdapter:
def __init__(self) -> None:
self._last_degradation: str | None = None
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]:
results = await self._attempt_search(query, max_results)
if results is None:
self._last_degradation = "duckduckgo rate-limited or unreachable; returning partial results"
return []
self._last_degradation = None
return results
async def _attempt_search(self, query: str, max_results: int) -> list[SearchResult] | None:
import asyncio
params = {"q": query}
headers = {"User-Agent": _USER_AGENT}
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=_SEARCH_TIMEOUT) as client:
response = await client.post(_DDG_URL, params=params, headers=headers)
if response.status_code in (403, 429):
if attempt == 0:
await asyncio.sleep(_BACKOFF_SECONDS)
continue
return None
response.raise_for_status()
return self._parse_results(response.text, max_results)
except httpx.HTTPError:
if attempt == 0:
await asyncio.sleep(_BACKOFF_SECONDS)
continue
return None
return None
def _parse_results(self, html: str, max_results: int) -> list[SearchResult]:
anchors = _RESULT_RE.findall(html)
snippets = [self._strip(s) for s in _SNIPPET_RE.findall(html)]
results: list[SearchResult] = []
for index, (href, title_html) in enumerate(anchors):
url = self._normalize_href(href)
if not url:
continue
snippet = snippets[index] if index < len(snippets) else ""
results.append(
SearchResult(
title=self._strip(title_html),
url=url,
snippet=snippet,
)
)
if len(results) >= max_results:
break
return results
def _normalize_href(self, href: str) -> str:
decoded = unescape(href)
parsed = urlparse(decoded)
if parsed.path.startswith("//duckduckgo.com/l/") or parsed.netloc == "duckduckgo.com":
target = parse_qs(parsed.query).get("uddg", [""])[0]
return unquote(target)
if decoded.startswith("http"):
return decoded
return ""
def _strip(self, html: str) -> str:
text = _TAG_RE.sub("", html)
return re.sub(r"\s+", " ", text).strip()
async def fetch(self, url: str) -> PageContent:
from urllib.parse import urljoin
current = url
async with httpx.AsyncClient(
timeout=_FETCH_TIMEOUT,
follow_redirects=False,
headers={"User-Agent": _USER_AGENT},
) as client:
for _ in range(_MAX_FETCH_REDIRECTS + 1):
if not url_allowed(current):
raise AdapterError(
"fetch refused: url not allowed (private or non-http scheme)"
)
response = await client.get(current)
if 300 <= response.status_code < 400:
location = response.headers.get("location")
if not location:
raise AdapterError("fetch failed: redirect without location")
current = urljoin(current, location)
continue
response.raise_for_status()
return PageContent(
url=url,
title=extract_title(response.text),
text=extract_text(response.text),
fetched_at=datetime.now(UTC),
)
raise AdapterError("fetch failed: too many redirects")
@property
def last_degradation(self) -> str | None:
return self._last_degradation
def resolve_search_adapter(
secrets: SecretsProvider,
) -> tuple[TavilySearchAdapter | DuckDuckGoSearchAdapter, str]:
if secrets.resolve("TAVILY_API_KEY"):
return TavilySearchAdapter(secrets), "tavily"
return DuckDuckGoSearchAdapter(), "duckduckgo-keyless"
+165
View File
@@ -0,0 +1,165 @@
from datetime import UTC, datetime
from researchx.adapters.model import ModelRoles
from researchx.adapters.types import (
AdapterError,
ChatResult,
ModelMessage,
PageContent,
SearchResult,
)
def _now() -> datetime:
return datetime.now(UTC)
class FakeModelAdapter:
def __init__(self, canned: list[ChatResult]) -> None:
self._canned = list(canned)
self._json_mode_calls: list[bool] = []
self.roles_value = ModelRoles(planner="fake-planner", writer="fake-writer")
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
del messages, temperature
self._json_mode_calls.append(json_mode)
if not self._canned:
raise AdapterError(f"no canned response for model {model}")
return self._canned.pop(0)
async def list_models(self) -> list[str]:
return ["fake-model"]
def roles(self) -> ModelRoles:
return self.roles_value
@property
def last_json_mode(self) -> bool | None:
return self._json_mode_calls[-1] if self._json_mode_calls else None
class FakeSearchAdapter:
def __init__(self, corpus: list[PageContent]) -> None:
self._corpus = list(corpus)
self._by_url = {page.url: page for page in corpus}
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]:
terms = [t.lower() for t in query.split() if t]
results: list[SearchResult] = []
for page in self._corpus:
haystack = f"{page.title}\n{page.text}".lower()
if all(t in haystack for t in terms):
snippet = page.text[:200]
results.append(SearchResult(title=page.title, url=page.url, snippet=snippet))
if len(results) >= max_results:
break
return results
async def fetch(self, url: str) -> PageContent:
page = self._by_url.get(url)
if page is None:
raise AdapterError(f"unknown url: {url}")
return page.model_copy(update={"fetched_at": _now()})
SHARED_SENTENCE = (
"The verification pass re-anchored every quoted claim to its stored passage "
"before the report was allowed to ship."
)
def corpus() -> list[PageContent]:
stamp = _now()
pages = [
PageContent(
url="https://example.org/verification",
title="Verification passes in research pipelines",
text=(
"Deep research pipelines fail when citations are decorative. "
f"{SHARED_SENTENCE} The pipeline treats every quote as a claim "
"about evidence, not a claim about the world.\n\n"
"Verification therefore runs twice: once at generation time and "
"once at export time — liveness is rechecked, anchors are "
"re-matched, and the ledger records the outcome."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/anchoring",
title="Quote anchoring and auditability",
text=(
f"{SHARED_SENTENCE} Anchoring is the mechanism that makes this "
"auditability real: each claim carries a fuzzy-matched pointer "
"back to the exact stored passage — d’abord normalisé, puis "
"comparé. 近似匹配的阈值设定为 0.85.\n\n"
"A claim without an anchor is not a finding; it is a hypothesis "
"with better typography."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/ollama",
title="OpenAI-compatible endpoints on Ollama",
text=(
"Ollama exposes an OpenAI-compatible surface at /v1: chat "
"completions accept the standard message array, and model "
"listing works via GET /v1/models. Cloud deployments require "
"a bearer key; localhost deployments are keyless by default.\n\n"
"Capability probing stays conservative: cap context at the "
"minimum of the model claim and 8192 tokens, and batch "
"embeddings at 8."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/search-routing",
title="Search routing across keyless and keyed providers",
text=(
"A keyed provider (Tavily) is the default route; a keyless "
"fallback (DuckDuckGo HTML) degrades gracefully under rate "
"limiting. The fallback is a degraded mode, not a second "
"source — its warnings surface in the run trace.\n\n"
"Routing decisions belong to the methodology dials, never to "
"the writer."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/state-machines",
title="Durable state machines for research engines",
text=(
"A research engine survives restarts by persisting state after "
"every transition: an atomic tmp-plus-replace write leaves no "
"torn files, and a cursor records the depth-loop position.\n\n"
"Pause clears an asyncio event; cancel persists a terminal "
"state; resume re-enters the loop at the recorded cursor. "
"CancelledError is re-raised after the write — never swallowed."
),
fetched_at=stamp,
),
PageContent(
url="https://example.org/methodology",
title="Methodology-first research selection",
text=(
"Choosing a methodology before a single query fires converts "
"ad-hoc prompt crafting into a structured, reproducible "
"decision. Dials — breadth, depth, citation rigor, "
"verification passes — resolve into a snapshot that persists "
"with every run.\n\n"
"The methodology registry ships Rapid Answer for memo-shaped "
"answers and Deep Report for executive reports with full "
"verification."
),
fetched_at=stamp,
),
]
return pages
CORPUS = corpus()
@@ -0,0 +1,73 @@
import asyncio
import os
import re
import secrets as secrets_module
from pathlib import Path
from researchx.adapters.types import AdapterError
_RUN_ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
_RUN_ID_RE = re.compile(r"^[a-z0-9]{8}$")
_ARTIFACT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def generate_run_id() -> str:
return "".join(secrets_module.choice(_RUN_ID_ALPHABET) for _ in range(8))
def _validate_run_id(run_id: str) -> None:
if not _RUN_ID_RE.match(run_id):
raise AdapterError("invalid run id")
def _validate_artifact_name(name: str) -> None:
if not _ARTIFACT_NAME_RE.match(name) or Path(name).name != name:
raise AdapterError("invalid artifact name")
class LocalStorageAdapter:
def __init__(self, base_dir: Path) -> None:
self._base_dir = base_dir
def run_dir(self, run_id: str) -> Path:
_validate_run_id(run_id)
return self._base_dir / "runs" / run_id
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path:
_validate_artifact_name(name)
target = self.run_dir(run_id) / name
target.parent.mkdir(parents=True, exist_ok=True)
tmp_path = target.with_name(f"{target.name}.{secrets_module.token_hex(4)}.tmp")
def _write_atomic() -> None:
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(target)
try:
await asyncio.to_thread(_write_atomic)
finally:
tmp_path.unlink(missing_ok=True)
return target
async def read_run_artifact(self, run_id: str, name: str) -> str:
_validate_artifact_name(name)
target = self.run_dir(run_id) / name
try:
return target.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise AdapterError(f"artifact not found: runs/{run_id}/{name}") from exc
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path:
_validate_artifact_name(name)
target = self.run_dir(run_id) / name
target.parent.mkdir(parents=True, exist_ok=True)
def _append() -> None:
with open(target, "a", encoding="utf-8") as handle:
handle.write(content)
await asyncio.to_thread(_append)
return target
+8
View File
@@ -0,0 +1,8 @@
from typing import Protocol, runtime_checkable
@runtime_checkable
class MemoryAdapter(Protocol):
async def remember(self, scope: str, key: str, value: str) -> None: ...
async def recall(self, scope: str, key: str) -> str | None: ...
+26
View File
@@ -0,0 +1,26 @@
from typing import Protocol, runtime_checkable
from pydantic import BaseModel
from researchx.adapters.types import ChatResult, ModelMessage
class ModelRoles(BaseModel):
planner: str
writer: str
@runtime_checkable
class ModelAdapter(Protocol):
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult: ...
async def list_models(self) -> list[str]: ...
def roles(self) -> ModelRoles: ...
+118
View File
@@ -0,0 +1,118 @@
from typing import Any
import httpx
from researchx.adapters.model import ModelRoles
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage, Usage
_DEFAULT_CLOUD_BASE = "https://ollama.com/v1"
_DEFAULT_LOCAL_BASE = "http://localhost:11434/v1"
_REQUEST_TIMEOUT = 60.0
class OllamaModelAdapter:
probing_supported = True
def __init__(
self,
secrets: EnvFileSecretsProvider,
planner_model: str,
writer_model: str,
cloud_base_url: str | None = None,
local_base_url: str | None = None,
) -> None:
self._secrets = secrets
self._roles = ModelRoles(planner=planner_model, writer=writer_model)
self._cloud_base = (cloud_base_url or secrets.resolve("OLLAMA_BASE_URL") or _DEFAULT_CLOUD_BASE).rstrip("/")
self._local_base = (local_base_url or _DEFAULT_LOCAL_BASE).rstrip("/")
self._active_base = self._cloud_base
self._api_key = secrets.resolve("OLLAMA_API_KEY")
self._last_fallback_reason: str | None = None
def _headers(self, base_url: str) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if base_url == self._cloud_base and self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
return headers
async def _post_chat(
self,
base_url: str,
payload: dict[str, object],
) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
response = await client.post(
f"{base_url}/chat/completions",
json=payload,
headers=self._headers(base_url),
)
try:
response.raise_for_status()
except httpx.HTTPError as exc:
raise AdapterError(f"chat request failed ({type(exc).__name__})") from exc
data: dict[str, Any] = response.json()
return data
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
payload: dict[str, object] = {
"model": model,
"messages": [m.model_dump() for m in messages],
"temperature": temperature,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
try:
data = await self._post_chat(self._active_base, payload)
except AdapterError as first_error:
if self._active_base == self._cloud_base:
self._last_fallback_reason = f"cloud endpoint failed: {type(first_error.__cause__).__name__ if first_error.__cause__ else 'Error'}"
self._active_base = self._local_base
data = await self._post_chat(self._local_base, payload)
else:
raise
choice = data["choices"][0]["message"]
raw_usage = data.get("usage") or {}
usage = Usage(
prompt_tokens=raw_usage.get("prompt_tokens"),
completion_tokens=raw_usage.get("completion_tokens"),
)
return ChatResult(content=choice["content"], model=model, usage=usage)
async def list_models(self) -> list[str]:
try:
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
response = await client.get(
f"{self._active_base}/models",
headers=self._headers(self._active_base),
)
try:
response.raise_for_status()
except httpx.HTTPError as exc:
raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc
data: dict[str, Any] = response.json()
return [str(entry["id"]) for entry in data.get("data", [])]
except (AdapterError, httpx.HTTPError):
if self._active_base == self._cloud_base:
self._last_fallback_reason = "cloud models listing failed; using local"
self._active_base = self._local_base
return await self.list_models()
raise
def roles(self) -> ModelRoles:
return self._roles
@property
def active_base_url(self) -> str:
return self._active_base
@property
def last_fallback_reason(self) -> str | None:
return self._last_fallback_reason
+34
View File
@@ -0,0 +1,34 @@
from pydantic import BaseModel
DEFAULT_CONTEXT_CAP = 8192
DEFAULT_EMBEDDING_BATCH = 8
class CapabilityProbe(BaseModel):
endpoint: str
models: list[str]
context_window: int
embedding_batch: int
structured_outputs_supported: bool
structured_output_warning: str | None = None
fallback_reason: str | None = None
@property
def degraded(self) -> bool:
return self.structured_output_warning is not None or self.fallback_reason is not None
def cap_context_window(model_claim: int | None) -> int:
if model_claim is None or model_claim <= 0:
return DEFAULT_CONTEXT_CAP
return min(model_claim, DEFAULT_CONTEXT_CAP)
def structured_output_warning(supported: bool, model_name: str) -> str | None:
if supported:
return None
return (
f"Model '{model_name}' did not confirm structured-output (function-calling) support. "
"Methodologies needing structured outputs will degrade to a prompt-embedded JSON "
"contract — expect looser parsing, not a hard failure."
)
+10
View File
@@ -0,0 +1,10 @@
from typing import Protocol, runtime_checkable
from researchx.adapters.types import PageContent, SearchResult
@runtime_checkable
class SearchAdapter(Protocol):
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]: ...
async def fetch(self, url: str) -> PageContent: ...
+32
View File
@@ -0,0 +1,32 @@
from pathlib import Path
from typing import Protocol, runtime_checkable
@runtime_checkable
class SecretsProvider(Protocol):
def resolve(self, name: str) -> str | None: ...
def _parse_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip("'\"")
return values
class EnvFileSecretsProvider:
def __init__(self, paths: list[Path]) -> None:
self._paths = [p for p in paths if p.is_file()]
self._values: dict[str, str] = {}
for path in self._paths:
self._values.update(_parse_env_file(path))
def resolve(self, name: str) -> str | None:
return self._values.get(name)
def __repr__(self) -> str:
return f"EnvFileSecretsProvider(paths={[p.name for p in self._paths]})"
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from typing import Protocol, runtime_checkable
@runtime_checkable
class StorageAdapter(Protocol):
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path: ...
async def read_run_artifact(self, run_id: str, name: str) -> str: ...
def run_dir(self, run_id: str) -> Path: ...
+87
View File
@@ -0,0 +1,87 @@
import re
from datetime import UTC, datetime
import httpx
from researchx.adapters.secrets import SecretsProvider
from researchx.adapters.types import AdapterError, PageContent, SearchResult
from researchx.adapters.url_guard import url_allowed
_TAVILY_URL = "https://api.tavily.com/search"
_SEARCH_TIMEOUT = 10.0
_FETCH_TIMEOUT = 10.0
_MAX_FETCH_REDIRECTS = 5
_USER_AGENT = "researchx/0.2 (local research studio)"
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
_BLOCK_RE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
_TAG_RE = re.compile(r"<[^>]+>")
def extract_text(html: str) -> str:
text = _BLOCK_RE.sub(" ", html)
text = _TAG_RE.sub(" ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def extract_title(html: str) -> str:
match = _TITLE_RE.search(html)
return match.group(1).strip() if match else ""
class TavilySearchAdapter:
def __init__(self, secrets: SecretsProvider) -> None:
self._api_key = secrets.resolve("TAVILY_API_KEY")
if not self._api_key:
raise AdapterError("TAVILY_API_KEY not present in secrets scope")
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]:
payload = {
"api_key": self._api_key,
"query": query,
"search_depth": "basic",
"max_results": max_results,
"include_answer": False,
}
async with httpx.AsyncClient(timeout=_SEARCH_TIMEOUT) as client:
response = await client.post(_TAVILY_URL, json=payload)
response.raise_for_status()
data = response.json()
return [
SearchResult(
title=str(entry.get("title", "")),
url=str(entry.get("url", "")),
snippet=str(entry.get("content", "")),
)
for entry in data.get("results", [])
][:max_results]
async def fetch(self, url: str) -> PageContent:
from urllib.parse import urljoin
current = url
async with httpx.AsyncClient(
timeout=_FETCH_TIMEOUT,
follow_redirects=False,
headers={"User-Agent": _USER_AGENT},
) as client:
for _ in range(_MAX_FETCH_REDIRECTS + 1):
if not url_allowed(current):
raise AdapterError(
"fetch refused: url not allowed (private or non-http scheme)"
)
response = await client.get(current)
if 300 <= response.status_code < 400:
location = response.headers.get("location")
if not location:
raise AdapterError("fetch failed: redirect without location")
current = urljoin(current, location)
continue
response.raise_for_status()
return PageContent(
url=url,
title=extract_title(response.text),
text=extract_text(response.text),
fetched_at=datetime.now(UTC),
)
raise AdapterError("fetch failed: too many redirects")
+36
View File
@@ -0,0 +1,36 @@
from datetime import datetime
from pydantic import BaseModel
class ModelMessage(BaseModel):
role: str
content: str
class Usage(BaseModel):
prompt_tokens: int | None = None
completion_tokens: int | None = None
class ChatResult(BaseModel):
content: str
model: str
usage: Usage | None = None
class SearchResult(BaseModel):
title: str
url: str
snippet: str
class PageContent(BaseModel):
url: str
title: str
text: str
fetched_at: datetime
class AdapterError(Exception):
pass
@@ -0,0 +1,44 @@
import socket
from ipaddress import IPv4Address, IPv6Address
from urllib.parse import urlparse
def _host_is_private(hostname: str) -> bool:
try:
addr_info = socket.getaddrinfo(hostname, None)
except (socket.gaierror, UnicodeError):
return True
for family, _, _, _, sockaddr in addr_info:
try:
if family == socket.AF_INET6:
ip: IPv4Address | IPv6Address = IPv6Address(sockaddr[0])
else:
ip = IPv4Address(sockaddr[0])
except ValueError:
return True
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
):
return True
return False
def url_allowed(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
if not parsed.hostname:
return False
return not _host_is_private(parsed.hostname)
class FetchRefusedError(Exception):
pass
__all__ = ["FetchRefusedError", "url_allowed"]
+358
View File
@@ -0,0 +1,358 @@
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ValidationError
from sse_starlette.sse import EventSourceResponse
from researchx.adapters.capability_probe import probe_model_adapter, supports_probe
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import AdapterError, ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus, RunEvent
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.methodologies import registry
_CANNED_PLAN = (
'{"sub_questions": ["What is the research question?"], '
'"source_universe": ["web"], "outline": ["approach", "findings", "conclusion"]}'
)
_CANNED_WRITER = (
"# Research Memo\n\n"
"The verified core finding [p-1]. A second corroborated observation [p-2].\n"
)
class CreateRunBody(BaseModel):
question: str = Field(min_length=1, max_length=2000)
context: str = Field(default="", max_length=8000)
methodology: str
dial_overrides: dict[str, Any] = Field(default_factory=dict)
class ApproveBody(BaseModel):
plan: dict[str, Any] | None = None
class RunSummary(BaseModel):
id: str
status: str
question: str
methodology: str
plan: dict[str, Any] | None = None
started_at: str
class MethodologyCard(BaseModel):
slug: str
name: str
description: str
eta_minutes: int
cost_estimate: str
why_recommended: str
output_shape: str
dials: dict[str, Any]
class ConfigStatus(BaseModel):
model_adapter: str
search_adapter: str
storage_dir: str
probe: dict[str, Any] | None = None
cost_counters: dict[str, int]
_STATUS_MAP = {
RunStateName.PLANNING: "planning",
RunStateName.AWAITING_APPROVAL: "awaiting_approval",
RunStateName.SEARCHING: "searching",
RunStateName.EXTRACTING: "extracting",
RunStateName.REFLECTING: "reflecting",
RunStateName.SYNTHESIZING: "synthesizing",
RunStateName.VERIFYING: "verifying",
RunStateName.DONE: "done",
RunStateName.CANCELLED: "cancelled",
RunStateName.FAILED: "failed",
}
_ETA_TABLE = {"rapid-answer": (2, "low"), "deep-report": (12, "medium")}
def _cost_estimate(slug: str) -> str:
depth, breadth = 1, 1
methodology = registry.get(slug)
depth = methodology.dials.depth
breadth = methodology.dials.breadth
units = depth * breadth
if units <= 1:
return "~5k tokens (estimate)"
if units <= 4:
return "~25k tokens (estimate)"
return "~60k tokens (estimate)"
def _why_recommended(slug: str) -> str:
return {
"rapid-answer": "One pass, one memo — fastest path to a confident, cited answer.",
"deep-report": "Iterative depth with full verification — when the answer must stand up to scrutiny.",
}.get(slug, "Methodology-first research.")
class RunRegistry:
def __init__(self) -> None:
self._actors: dict[str, tuple[RunActor, asyncio.Task[RunState]]] = {}
self._buses: dict[str, EventBus] = {}
self.cost_counters: dict[str, int] = {"runs_created": 0, "runs_completed": 0}
def add(self, run_id: str, actor: RunActor, task: asyncio.Task[RunState], bus: EventBus) -> None:
self._actors[run_id] = (actor, task)
self._buses[run_id] = bus
self.cost_counters["runs_created"] += 1
def actor(self, run_id: str) -> RunActor:
entry = self._actors.get(run_id)
if entry is None:
raise KeyError(run_id)
return entry[0]
def bus(self, run_id: str) -> EventBus:
bus = self._buses.get(run_id)
if bus is None:
raise KeyError(run_id)
return bus
def has(self, run_id: str) -> bool:
return run_id in self._actors
def note_completed(self) -> None:
self.cost_counters["runs_completed"] += 1
def create_app(
*,
storage_dir: Path | None = None,
model_adapter: Any = None,
search_adapter: Any = None,
) -> FastAPI:
app = FastAPI(title="ResearchX API", version="0.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
storage = LocalStorageAdapter(base_dir=storage_dir or Path.cwd() / ".researchx")
runs = RunRegistry()
model = model_adapter or FakeModelAdapter(
canned=[
ChatResult(content=_CANNED_PLAN, model="fake-planner"),
ChatResult(content=_CANNED_WRITER, model="fake-writer"),
]
)
search = search_adapter or FakeSearchAdapter(CORPUS)
def _summary(run_id: str) -> RunSummary:
actor = runs.actor(run_id)
state = actor.state
return RunSummary(
id=run_id,
status=_STATUS_MAP[state.state],
question=state.question,
methodology=state.methodology_slug,
plan=state.plan.model_dump(mode="json") if state.plan else None,
started_at=state.created_at.isoformat(),
)
@app.get("/api/config")
async def config_status() -> ConfigStatus:
latest_probe = None
for run_id in list(runs._actors)[-1:]:
actor_probe = runs.actor(run_id).state.probe
latest_probe = actor_probe
return ConfigStatus(
model_adapter=type(model).__name__,
search_adapter=type(search).__name__,
storage_dir=str(storage.run_dir("00000000").parent),
probe=latest_probe,
cost_counters=runs.cost_counters,
)
@app.get("/api/methodologies")
async def methodologies() -> list[MethodologyCard]:
cards: list[MethodologyCard] = []
for methodology in registry.list():
eta_minutes, _ = _ETA_TABLE.get(methodology.slug, (5, "low"))
cards.append(
MethodologyCard(
slug=methodology.slug,
name=methodology.name,
description=methodology.description,
eta_minutes=eta_minutes,
cost_estimate=_cost_estimate(methodology.slug),
why_recommended=_why_recommended(methodology.slug),
output_shape=methodology.output_shape,
dials=methodology.dials.model_dump(mode="json"),
)
)
return cards
@app.post("/api/runs", status_code=201)
async def create_run(body: CreateRunBody) -> RunSummary:
if body.methodology not in registry:
raise HTTPException(status_code=404, detail="unknown methodology")
try:
run_id, config = make_run_config(
question=body.question,
context=body.context,
methodology_slug=body.methodology,
overrides=body.dial_overrides,
registry=registry,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=422, detail="invalid dial override") from exc
bus = EventBus()
actor = RunActor(
run_config=config,
model=model,
search=search,
storage=storage,
registry=registry,
event_bus=bus,
on_complete=runs.note_completed,
)
probe_data: dict[str, Any] | None = None
if supports_probe(model):
try:
probe = await probe_model_adapter(model)
probe_data = probe.model_dump(mode="json")
await actor.attach_probe(probe_data)
except AdapterError:
probe_data = None
task = asyncio.create_task(actor.run())
runs.add(run_id, actor, task, bus)
async def _watch() -> None:
await task
asyncio.create_task(_watch())
return _summary(run_id)
@app.get("/api/runs/{run_id}")
async def get_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
return _summary(run_id)
@app.post("/api/runs/{run_id}/approve")
async def approve_run(run_id: str, body: ApproveBody | None = None) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
actor = runs.actor(run_id)
edited_plan: Plan | None = None
if body and body.plan:
try:
edited_plan = Plan.model_validate(body.plan)
except ValidationError:
raise HTTPException(status_code=422, detail="invalid plan")
await actor.approve(edited_plan)
return _summary(run_id)
@app.post("/api/runs/{run_id}/pause")
async def pause_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
runs.actor(run_id).pause()
return _summary(run_id)
@app.post("/api/runs/{run_id}/resume")
async def resume_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
runs.actor(run_id).resume()
return _summary(run_id)
@app.post("/api/runs/{run_id}/cancel")
async def cancel_run(run_id: str) -> RunSummary:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
actor = runs.actor(run_id)
actor.cancel()
actor.resume()
return _summary(run_id)
@app.get("/api/runs/{run_id}/events")
async def run_events(run_id: str) -> EventSourceResponse:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
bus = runs.bus(run_id)
actor = runs.actor(run_id)
terminal = {RunStateName.DONE, RunStateName.CANCELLED, RunStateName.FAILED}
async def event_stream() -> AsyncIterator[dict[str, str]]:
queue: asyncio.Queue[RunEvent] = asyncio.Queue()
async def forward(event: RunEvent) -> None:
await queue.put(event)
bus.subscribe(forward)
try:
replay = bus.replay()
watermark = replay[-1].seq if replay else 0
for prior in replay:
yield {"event": prior.type, "data": prior.model_dump_json()}
while True:
if actor.state.state in terminal:
try:
event = await asyncio.wait_for(queue.get(), timeout=0.5)
except TimeoutError:
return
else:
event = await queue.get()
if event.seq <= watermark:
continue
yield {"event": event.type, "data": event.model_dump_json()}
if event.type in ("run_done", "run_error"):
return
finally:
bus.unsubscribe(forward)
return EventSourceResponse(event_stream())
return EventSourceResponse(event_stream())
@app.get("/api/runs/{run_id}/report")
async def run_report(run_id: str) -> dict[str, Any]:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "report.md")
except AdapterError:
raise HTTPException(status_code=404, detail="report not ready")
return {"run_id": run_id, "markdown": content}
@app.get("/api/runs/{run_id}/ledger")
async def run_ledger(run_id: str) -> dict[str, Any]:
if not runs.has(run_id):
raise HTTPException(status_code=404, detail="run not found")
try:
content = await storage.read_run_artifact(run_id, "ledger.json")
except AdapterError:
raise HTTPException(status_code=404, detail="ledger not ready")
import json
data: dict[str, Any] = json.loads(content)
return data
return app
app = create_app()
+85
View File
@@ -0,0 +1,85 @@
"""Dev bridge: serve the FastAPI app on a loopback port for E2E tests.
Prints READY on stdout once the server is listening; SIGTERM shuts
down cleanly. Single-user, loopback-only, disposable — for tests and
local dev. Uses an inexhaustible scripted model so many sequential
runs work without restocking canned responses.
"""
import contextlib
import json
import sys
import tempfile
from pathlib import Path
from typing import Any
import uvicorn
from researchx.adapters.fakes import CORPUS, FakeSearchAdapter
from researchx.adapters.types import ChatResult, ModelMessage
from researchx.api.app import create_app
_HOST = "127.0.0.1"
_PORT = 8000
_PLAN = json.dumps(
{
"sub_questions": ["verification passes"],
"source_universe": ["web"],
"outline": ["approach", "findings"],
}
)
_WRITER = (
"# Research Memo\n\n"
"The verified core finding [p-1]. A corroborated observation [p-2].\n"
)
class EndlessModelAdapter:
def __init__(self) -> None:
self.calls = 0
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
del messages, temperature
self.calls += 1
if json_mode:
return ChatResult(content=_PLAN, model=model)
return ChatResult(content=_WRITER, model=model)
async def list_models(self) -> list[str]:
return ["endless-model"]
def roles(self) -> Any:
from researchx.adapters.model import ModelRoles
return ModelRoles(planner="endless-model", writer="endless-model")
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
app = create_app(
storage_dir=Path(tmp),
model_adapter=EndlessModelAdapter(),
search_adapter=FakeSearchAdapter(CORPUS),
)
@app.on_event("startup")
def _announce() -> None:
print(f"READY port={_PORT}", flush=True)
config = uvicorn.Config(app, host=_HOST, port=_PORT, log_level="warning")
server = uvicorn.Server(config)
with contextlib.suppress(KeyboardInterrupt):
server.run()
sys.exit(0)
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
from types import MappingProxyType
from typing import Literal
from pydantic import BaseModel, Field
SourceUniverse = Literal["web", "academic", "local", "web+academic"]
SearchRouting = Literal["single", "adaptive", "academic-only"]
QueryStrategy = Literal["fixed", "iterative", "decomposed"]
QualityGates = Literal["none", "standard", "strict"]
CitationRigor = Literal["per-claim", "per-section", "per-report"]
SynthesisStyle = Literal["memo", "executive", "encyclopedic", "adversarial"]
OutputStructure = Literal["brief", "report", "dossier"]
MemoryScope = Literal["none", "project", "cross-project"]
class DialDescriptor(BaseModel, frozen=True):
"""UI display metadata for a single dial."""
name: str
label: str
description: str
class Dials(BaseModel, frozen=True):
"""The 12 methodology dials with safe defaults (REQ-01)."""
source_universe: SourceUniverse = "web"
search_routing: SearchRouting = "single"
query_strategy: QueryStrategy = "decomposed"
breadth: int = Field(default=2, ge=1, le=5)
depth: int = Field(default=1, ge=1, le=3)
quality_gates: QualityGates = "standard"
citation_rigor: CitationRigor = "per-claim"
verification_passes: int = Field(default=1, ge=0, le=3)
synthesis_style: SynthesisStyle = "memo"
output_structure: OutputStructure = "brief"
memory_scope: MemoryScope = "none"
checkpoints: bool = False
DIAL_REGISTRY: MappingProxyType[str, DialDescriptor] = MappingProxyType(
{
"source_universe": DialDescriptor(
name="source_universe",
label="Source universe",
description="Which sources may be searched: web, academic, local, or both.",
),
"search_routing": DialDescriptor(
name="search_routing",
label="Search routing",
description="How queries are routed: single endpoint, adaptive, or academic-only.",
),
"query_strategy": DialDescriptor(
name="query_strategy",
label="Query strategy",
description="How the question becomes queries: fixed, iterative, or decomposed.",
),
"breadth": DialDescriptor(
name="breadth",
label="Breadth",
description="Number of parallel sub-question searches (1-5).",
),
"depth": DialDescriptor(
name="depth",
label="Depth",
description="Number of reflection and refinement loops (1-3).",
),
"quality_gates": DialDescriptor(
name="quality_gates",
label="Quality gates",
description="How strictly sources are filtered before synthesis.",
),
"citation_rigor": DialDescriptor(
name="citation_rigor",
label="Citation rigor",
description="Granularity of citations: per claim, per section, or per report.",
),
"verification_passes": DialDescriptor(
name="verification_passes",
label="Verification passes",
description="Post-synthesis verification passes over claims (0-3).",
),
"synthesis_style": DialDescriptor(
name="synthesis_style",
label="Synthesis style",
description="Voice of the synthesized report.",
),
"output_structure": DialDescriptor(
name="output_structure",
label="Output structure",
description="Shape of the final artifact: brief, report, or dossier.",
),
"memory_scope": DialDescriptor(
name="memory_scope",
label="Memory scope",
description="What prior context a run may draw on.",
),
"checkpoints": DialDescriptor(
name="checkpoints",
label="Checkpoints",
description="Persist intermediate state so a run can pause and resume.",
),
}
)
__all__ = [
"DIAL_REGISTRY",
"DialDescriptor",
"Dials",
]
@@ -0,0 +1,49 @@
import secrets
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from researchx.config.dials import Dials
from researchx.methodologies.registry import MethodologyRegistry
class UnknownOverrideError(ValueError):
"""Raised when an override key is not a dial name."""
class RunConfig(BaseModel, frozen=True):
"""Persisted per-run snapshot of resolved dials (REQ-04)."""
run_id: str = Field(pattern=r"^[a-z0-9]{8}$")
methodology_slug: str
question: str = Field(min_length=1)
context: str = ""
resolved_dials: Dials
created_at: datetime
overrides: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(frozen=True)
def resolve_dials(
methodology_slug: str,
overrides: Mapping[str, Any],
registry: MethodologyRegistry,
) -> Dials:
"""Resolve a methodology's dial bundle with validated overrides applied."""
bundle = registry.get(methodology_slug).dials
for key in overrides:
if key not in Dials.model_fields:
raise UnknownOverrideError(f"unknown dial override: {key}")
merged = bundle.model_dump()
merged.update(overrides)
return Dials.model_validate(merged)
def new_run_id() -> str:
return secrets.token_hex(4)
__all__ = ["RunConfig", "UnknownOverrideError", "new_run_id", "resolve_dials"]
+373
View File
@@ -0,0 +1,373 @@
import asyncio
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
import httpx
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.model import ModelAdapter
from researchx.adapters.search import SearchAdapter
from researchx.adapters.types import AdapterError, ModelMessage
from researchx.adapters.url_guard import url_allowed
from researchx.config.dials import Dials
from researchx.config.run_config import RunConfig, resolve_dials
from researchx.engine.events import EventBus, RunEvent
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.evidence.store import EvidenceStore
from researchx.methodologies import MethodologyRegistry
from researchx.trust.ledger import build_ledger, export_checklist
from researchx.trust.liveness import liveness_report
class EngineError(Exception):
pass
class RunActor:
def __init__(
self,
*,
run_config: RunConfig,
model: ModelAdapter,
search: SearchAdapter,
storage: LocalStorageAdapter,
registry: MethodologyRegistry,
event_bus: EventBus,
on_complete: Callable[[], None] | None = None,
) -> None:
self._config = run_config
self._model = model
self._search = search
self._storage = storage
self._registry = registry
self._bus = event_bus
self._on_complete = on_complete
self._store = EvidenceStore()
self.state = RunState(
run_id=run_config.run_id,
question=run_config.question,
context=run_config.context,
methodology_slug=run_config.methodology_slug,
)
methodology = registry.get(run_config.methodology_slug)
self._dials: Dials = run_config.resolved_dials
self.state.depth_target = self._dials.depth
self._methodology = methodology
self._pause = asyncio.Event()
self._pause.set()
self._cancelled = False
self._approved = asyncio.Event()
async def _publish(self, event_type: str, data: dict[str, str] | None = None) -> None:
event = RunEvent(
type=event_type,
run_id=self._config.run_id,
data=data or {},
)
await self._bus.publish(event)
try:
await self._storage.append_run_artifact(
self._config.run_id,
"trace.jsonl",
event.model_dump_json() + "\n",
)
except AdapterError:
pass
async def _persist(self) -> None:
content = self._snapshot()
await self._storage.write_run_artifact(self._config.run_id, "state.json", content)
def _snapshot(self) -> str:
return self.state.model_dump_json()
async def _checkpoint(self, target: RunStateName) -> None:
if self.state.state != target:
self.state.transition(target)
if target is RunStateName.DONE and self._on_complete is not None:
self._on_complete()
await self._persist()
async def _wait_if_paused(self) -> None:
await self._pause.wait()
def pause(self) -> None:
self._pause.clear()
def resume(self) -> None:
self._pause.set()
def cancel(self) -> None:
self._cancelled = True
async def approve(self, plan: Plan | None = None) -> None:
if plan is not None:
self.state.plan = plan
self._approved.set()
async def run(self) -> RunState:
await self._publish("run_started", {"question": self._config.question})
try:
await self._execute()
except asyncio.CancelledError:
await asyncio.shield(self._persist_cancelled())
raise
except AdapterError as exc:
await self._fail(str(exc))
raise EngineError(str(exc)) from exc
except httpx.HTTPError as exc:
await self._fail(f"network error ({type(exc).__name__})")
raise EngineError(f"network error ({type(exc).__name__})") from exc
except EngineError:
await self._fail("engine error")
raise
except Exception as exc:
await self._fail(f"unexpected engine failure ({type(exc).__name__})")
raise EngineError(f"unexpected engine failure ({type(exc).__name__})") from exc
return self.state
async def attach_probe(self, probe_data: dict[str, Any]) -> None:
self.state.probe = probe_data
await self._persist()
await self._publish("probe_attached", {"endpoint": str(probe_data.get("endpoint", ""))})
async def _persist_cancelled(self) -> None:
self.state.state = RunStateName.CANCELLED
await self._persist()
async def _fail(self, message: str) -> None:
await self._publish("run_error", {"message": message})
self.state.state = RunStateName.FAILED
await asyncio.shield(self._persist())
async def _execute(self) -> None:
current = self.state.state
if current == RunStateName.PLANNING:
await self._planning_phase()
elif current == RunStateName.AWAITING_APPROVAL:
pass
elif current in (
RunStateName.SEARCHING,
RunStateName.EXTRACTING,
RunStateName.REFLECTING,
RunStateName.SYNTHESIZING,
):
await self._research_phase()
await self._verify_phase()
return
elif current == RunStateName.VERIFYING:
await self._verify_phase()
return
else:
raise EngineError(f"cannot resume from state {current}")
await self._approval_phase()
await self._research_phase()
await self._verify_phase()
async def _planning_phase(self) -> None:
await self._checkpoint(RunStateName.PLANNING)
await self._wait_if_paused()
if self._cancelled:
await self._persist_cancelled()
raise asyncio.CancelledError
prompt = self._methodology.planner_prompt.format(
question=self._config.question, context=self._config.context
)
result = await self._model.chat(
[ModelMessage(role="user", content=prompt)],
model=self._model.roles().planner,
json_mode=True,
)
plan = self._parse_plan(result.content)
self.state.plan = plan
await self._publish("state_changed", {"state": "AWAITING_APPROVAL"})
def _parse_plan(self, raw: str) -> Plan:
import json
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise AdapterError("planner returned non-JSON plan") from exc
if not isinstance(data, dict):
raise AdapterError("planner plan is not a JSON object")
sub_questions = data.get("sub_questions", [])
source_universe = data.get("source_universe", [])
outline = data.get("outline", [])
if not (
isinstance(sub_questions, list)
and isinstance(source_universe, list)
and isinstance(outline, list)
):
raise AdapterError("planner plan has malformed lists")
return Plan(
sub_questions=[str(q) for q in sub_questions if isinstance(q, str)],
source_universe=[str(u) for u in source_universe if isinstance(u, str)],
outline=[str(o) for o in outline if isinstance(o, str)],
)
async def _approval_phase(self) -> None:
await self._checkpoint(RunStateName.AWAITING_APPROVAL)
while not self._approved.is_set():
if self._cancelled:
await self._persist_cancelled()
raise asyncio.CancelledError
try:
await asyncio.wait_for(self._approved.wait(), timeout=0.1)
except TimeoutError:
continue
await self._checkpoint(RunStateName.SEARCHING)
async def _research_phase(self) -> None:
while True:
await self._check_cancelled()
await self._searching_step()
await self._extracting_step()
if self.state.depth_cursor + 1 >= self.state.depth_target:
break
await self._reflecting_step()
self.state.depth_cursor += 1
await self._persist()
await self._synthesizing_step()
async def _check_cancelled(self) -> None:
if self._cancelled:
await self._persist_cancelled()
raise asyncio.CancelledError
async def _searching_step(self) -> None:
if not self.state.plan or not self.state.plan.sub_questions:
await self._checkpoint(RunStateName.SEARCHING)
return
await self._checkpoint(RunStateName.SEARCHING)
await self._wait_if_paused()
for sub_question in self.state.plan.sub_questions[: self._dials.breadth]:
await self._publish("query_fired", {"query": sub_question})
self.state.bump("queries")
results = await self._search.search(sub_question, max_results=3)
for result in results:
await self._publish(
"source_found",
{"url": result.url, "title": result.title},
)
async def _extracting_step(self) -> None:
await self._checkpoint(RunStateName.EXTRACTING)
await self._wait_if_paused()
if not self.state.plan:
return
for sub_question in self.state.plan.sub_questions[: self._dials.breadth]:
results = await self._search.search(sub_question, max_results=3)
for result in results:
if not url_allowed(result.url):
await self._publish("source_refused", {"url": result.url})
continue
try:
page = await self._search.fetch(result.url)
except (AdapterError, httpx.HTTPError):
continue
self._store.add_page(page.url, page.title, page.text)
await self._publish(
"source_visited",
{"url": page.url, "title": page.title},
)
self.state.bump("pages")
for passage in self._store.passages_for_url(page.url):
await self._publish(
"passage_extracted",
{"passage_id": passage.passage_id, "url": passage.url},
)
self.state.bump("passages")
async def _reflecting_step(self) -> None:
await self._checkpoint(RunStateName.REFLECTING)
await self._wait_if_paused()
prompt = self._methodology.planner_prompt.format(
question=self._config.question, context=self._config.context
)
result = await self._model.chat(
[ModelMessage(role="user", content=prompt)],
model=self._model.roles().planner,
json_mode=True,
)
if self.state.plan:
refined = self._parse_plan(result.content)
self.state.plan = refined
async def _synthesizing_step(self) -> None:
await self._checkpoint(RunStateName.SYNTHESIZING)
await self._wait_if_paused()
await self._check_cancelled()
context_block = "\n\n".join(
f"[{p.passage_id}] ({p.url}) {p.text}" for p in self._store.writer_context()
)
prompt = self._methodology.render_writer_prompt(
question=self._config.question,
context=self._config.context or "(none)",
plan=self.state.plan.model_dump_json() if self.state.plan else "{}",
evidence=context_block or "(no evidence collected)",
)
result = await self._model.chat(
[ModelMessage(role="user", content=prompt)],
model=self._model.roles().writer,
)
report = result.content
await self._storage.write_run_artifact(self._config.run_id, "report.md", report)
self.state.bump("writer_calls")
async def _verify_phase(self) -> None:
await self._checkpoint(RunStateName.VERIFYING)
report = await self._storage.read_run_artifact(self._config.run_id, "report.md")
liveness = await liveness_report(self._cited_urls(report))
ledger = build_ledger(
run_id=self._config.run_id,
report_text=report,
evidence=self._store,
liveness=liveness,
visited_at=datetime.now(UTC),
)
await self._storage.write_run_artifact(
self._config.run_id, "ledger.json", ledger.model_dump_json(indent=2)
)
checklist = export_checklist(ledger)
await self._storage.write_run_artifact(self._config.run_id, "checklist.md", checklist)
await self._checkpoint(RunStateName.DONE)
await self._publish("run_done", {"run_id": self._config.run_id})
def _cited_urls(self, report_text: str) -> list[str]:
import re
pattern = re.compile(r"\[p-(\d+)\]")
cited: list[str] = []
for match in pattern.finditer(report_text):
passage = self._store.get(f"p-{match.group(1)}")
if passage and passage.url not in cited:
cited.append(passage.url)
return cited
def make_run_config(
*,
question: str,
context: str,
methodology_slug: str,
overrides: dict[str, object],
registry: MethodologyRegistry,
run_id: str | None = None,
) -> tuple[str, RunConfig]:
from researchx.adapters.local_storage import generate_run_id
rid = run_id or generate_run_id()
dials = resolve_dials(methodology_slug, overrides, registry)
config = RunConfig(
run_id=rid,
methodology_slug=methodology_slug,
question=question,
context=context,
resolved_dials=dials,
overrides=dict(overrides),
created_at=datetime.now(UTC),
)
return rid, config
+68
View File
@@ -0,0 +1,68 @@
import asyncio
import tempfile
from pathlib import Path
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.methodologies import registry
async def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
run_id, config = make_run_config(
question="How do verification pipelines keep citations honest?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
)
plan = (
'{"sub_questions": ["verification passes", "quote anchoring"], '
'"source_universe": ["web"], "outline": ["intro", "findings"]}'
)
writer = (
"# Research Report\n\n"
"The verification pass re-anchored every quoted claim [p-1] "
"before the report shipped [p-2].\n"
)
model = FakeModelAdapter(
canned=[
ChatResult(content=plan, model="fake-planner"),
ChatResult(content=writer, model="fake-writer"),
]
)
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=base)
bus = EventBus()
actor = RunActor(
run_config=config, model=model, search=search, storage=storage, registry=registry,
event_bus=bus,
)
task = asyncio.create_task(actor.run())
while actor.state.state.value == "PLANNING":
await asyncio.sleep(0.01)
await actor.approve()
await task
state = (storage.run_dir(run_id) / "state.json").read_text()
report = (storage.run_dir(run_id) / "report.md").read_text()
ledger = (storage.run_dir(run_id) / "ledger.json").read_text()
checklist = (storage.run_dir(run_id) / "checklist.md").read_text()
print(f"run {run_id} → {actor.state.state.value}")
print(f"artifacts under {storage.run_dir(run_id)}:")
for name, content in [
("state.json", state),
("report.md", report),
("ledger.json", ledger),
("checklist.md", checklist),
]:
print(f"--- {name} ({len(content)} chars) ---")
print(f"events: {len(bus.replay())}")
if __name__ == "__main__":
asyncio.run(main())
+49
View File
@@ -0,0 +1,49 @@
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Literal
from pydantic import BaseModel, Field
class RunEvent(BaseModel):
type: str
run_id: str
at: datetime = Field(default_factory=lambda: datetime.now(UTC))
data: dict[str, str] = {}
seq: int = 0
class EventBus:
def __init__(self) -> None:
self._subscribers: list[Callable[[RunEvent], Awaitable[None]]] = []
self._log: list[RunEvent] = []
self._seq = 0
def subscribe(self, handler: Callable[[RunEvent], Awaitable[None]]) -> None:
self._subscribers.append(handler)
def unsubscribe(self, handler: Callable[[RunEvent], Awaitable[None]]) -> None:
if handler in self._subscribers:
self._subscribers.remove(handler)
async def publish(self, event: RunEvent) -> None:
self._seq += 1
event.seq = self._seq
self._log.append(event)
for handler in self._subscribers:
await handler(event)
def replay(self, after_seq: int = 0) -> list[RunEvent]:
return [e for e in self._log if e.seq > after_seq]
class TraceWriter:
def __init__(self, emit: Callable[[str], None]) -> None:
self._emit = emit
async def record(self, event: RunEvent) -> None:
self._emit(event.model_dump_json())
def event_type_of(event: RunEvent) -> Literal["run_started", "other"]:
return "run_started" if event.type == "run_started" else "other"
+36
View File
@@ -0,0 +1,36 @@
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel, Field
from researchx.engine.states import RunStateName, can_transition
class Plan(BaseModel):
sub_questions: list[str] = Field(default_factory=list)
source_universe: list[str] = Field(default_factory=list)
outline: list[str] = Field(default_factory=list)
class RunState(BaseModel):
run_id: str
question: str
context: str = ""
methodology_slug: str
state: RunStateName = RunStateName.PLANNING
plan: Plan | None = None
depth_cursor: int = 0
depth_target: int = 1
counters: dict[str, int] = Field(default_factory=dict)
probe: dict[str, Any] | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
def transition(self, target: RunStateName) -> None:
if not can_transition(self.state, target):
raise ValueError(f"illegal transition: {self.state} -> {target}")
self.state = target
self.updated_at = datetime.now(UTC)
def bump(self, key: str) -> None:
self.counters[key] = self.counters.get(key, 0) + 1
+65
View File
@@ -0,0 +1,65 @@
from enum import StrEnum
class RunStateName(StrEnum):
PLANNING = "PLANNING"
AWAITING_APPROVAL = "AWAITING_APPROVAL"
SEARCHING = "SEARCHING"
EXTRACTING = "EXTRACTING"
REFLECTING = "REFLECTING"
SYNTHESIZING = "SYNTHESIZING"
VERIFYING = "VERIFYING"
DONE = "DONE"
CANCELLED = "CANCELLED"
FAILED = "FAILED"
_LEGAL_TRANSITIONS: dict[RunStateName, set[RunStateName]] = {
RunStateName.PLANNING: {RunStateName.AWAITING_APPROVAL, RunStateName.FAILED},
RunStateName.AWAITING_APPROVAL: {
RunStateName.SEARCHING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.SEARCHING: {
RunStateName.EXTRACTING,
RunStateName.SYNTHESIZING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.EXTRACTING: {
RunStateName.REFLECTING,
RunStateName.SYNTHESIZING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.REFLECTING: {
RunStateName.SEARCHING,
RunStateName.SYNTHESIZING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.SYNTHESIZING: {
RunStateName.VERIFYING,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.VERIFYING: {
RunStateName.DONE,
RunStateName.CANCELLED,
RunStateName.FAILED,
},
RunStateName.DONE: set(),
RunStateName.CANCELLED: set(),
RunStateName.FAILED: set(),
}
TERMINAL_STATES = {
RunStateName.DONE,
RunStateName.CANCELLED,
RunStateName.FAILED,
}
def can_transition(current: RunStateName, target: RunStateName) -> bool:
return target in _LEGAL_TRANSITIONS[current]
+70
View File
@@ -0,0 +1,70 @@
from pydantic import BaseModel, Field
class Passage(BaseModel):
passage_id: str
url: str
quote: str
text: str
class VisitedPage(BaseModel):
url: str
title: str
passage_ids: list[str] = Field(default_factory=list)
def extract_passages(text: str, *, min_chars: int = 120) -> list[str]:
paragraphs = [p.strip() for p in text.split("\n") if p.strip()]
return [p for p in paragraphs if len(p) >= min_chars]
class EvidenceStore:
def __init__(self) -> None:
self._passages: dict[str, Passage] = {}
self._pages: dict[str, VisitedPage] = {}
self._seen_quotes: set[str] = set()
self._counter = 0
def add_page(self, url: str, title: str, text: str) -> VisitedPage:
page = self._pages.setdefault(url, VisitedPage(url=url, title=title))
for paragraph in extract_passages(text):
self.add_passage(url, paragraph)
return page
def add_passage(self, url: str, text: str) -> Passage:
self._counter += 1
passage_id = f"p-{self._counter}"
passage = Passage(passage_id=passage_id, url=url, quote=text[:200], text=text)
self._passages[passage_id] = passage
page = self._pages.get(url)
if page is None:
page = VisitedPage(url=url, title="")
self._pages[url] = page
page.passage_ids.append(passage_id)
self._seen_quotes.add(text[:200])
return passage
def has_seen(self, quote: str) -> bool:
return quote[:200] in self._seen_quotes
def get(self, passage_id: str) -> Passage | None:
return self._passages.get(passage_id)
def passages_for_url(self, url: str) -> list[Passage]:
page = self._pages.get(url)
if page is None:
return []
return [self._passages[pid] for pid in page.passage_ids if pid in self._passages]
def writer_context(self) -> list[Passage]:
return [self._passages[pid] for pid in sorted(self._passages, key=self._passage_order)]
def _passage_order(self, passage_id: str) -> int:
return int(passage_id.split("-")[1])
def pages(self) -> list[VisitedPage]:
return list(self._pages.values())
def __len__(self) -> int:
return len(self._passages)
@@ -0,0 +1,9 @@
from researchx.methodologies.builtin import registry
from researchx.methodologies.models import Methodology
from researchx.methodologies.registry import MethodologyRegistry
__all__ = [
"Methodology",
"MethodologyRegistry",
"registry",
]
@@ -0,0 +1,97 @@
from researchx.config.dials import Dials
from researchx.methodologies.models import Methodology
from researchx.methodologies.registry import MethodologyRegistry
_RAPID_PLANNER_PROMPT = (
"You are a rapid research planner. Decompose the user's question into the fewest "
"focused sub-questions that yield a confident answer.\n"
"Question: {question}\n"
"Context: {context}"
)
_RAPID_WRITER_PROMPT = (
"You are a rapid memo writer. Answer the question directly in a short memo.\n"
"Cite a source per claim using the passage markers [p-N]. Every cited quote must "
"come verbatim from the evidence passages listed below and from nowhere else.\n"
"Question: {question}\n"
"Context: {context}\n"
"Plan: {plan}\n"
"Evidence passages:\n{evidence}"
)
_DEEP_PLANNER_PROMPT = (
"You are a deep research planner. Decompose the user's question into two "
"sub-questions, then propose an outline for an executive report with "
"sections, each mapped to the sub-questions it answers.\n"
"Question: {question}\n"
"Context: {context}"
)
_DEEP_WRITER_PROMPT = (
"You are an executive report writer. Synthesize the evidence into a structured "
"executive report following the plan: executive summary, findings, risks, and "
"recommendations.\n"
"Cite a source per claim using the passage markers [p-N]. Every cited quote must "
"come verbatim from the evidence passages listed below and from nowhere else.\n"
"Question: {question}\n"
"Context: {context}\n"
"Plan: {plan}\n"
"Evidence passages:\n{evidence}"
)
_BUILTIN: dict[str, Methodology] = {
"rapid-answer": Methodology(
slug="rapid-answer",
name="Rapid Answer",
description=(
"One search pass, one synthesis pass: a short cited memo for questions "
"that need a confident answer fast."
),
dials=Dials(
source_universe="web",
search_routing="single",
query_strategy="decomposed",
breadth=1,
depth=1,
quality_gates="standard",
citation_rigor="per-claim",
verification_passes=1,
synthesis_style="memo",
output_structure="brief",
memory_scope="none",
checkpoints=False,
),
planner_prompt=_RAPID_PLANNER_PROMPT,
writer_prompt=_RAPID_WRITER_PROMPT,
output_shape="memo",
),
"deep-report": Methodology(
slug="deep-report",
name="Deep Report",
description=(
"Full-depth research: iterative sub-questions, reflective refinement, and "
"a verified executive report with per-claim citations."
),
dials=Dials(
source_universe="web+academic",
search_routing="adaptive",
query_strategy="decomposed",
breadth=2,
depth=3,
quality_gates="strict",
citation_rigor="per-claim",
verification_passes=2,
synthesis_style="executive",
output_structure="report",
memory_scope="none",
checkpoints=True,
),
planner_prompt=_DEEP_PLANNER_PROMPT,
writer_prompt=_DEEP_WRITER_PROMPT,
output_shape="executive-report",
),
}
registry = MethodologyRegistry(_BUILTIN)
__all__ = ["registry"]
@@ -0,0 +1,32 @@
from typing import Literal
from pydantic import BaseModel, Field
from researchx.config.dials import Dials
OutputShape = Literal["memo", "executive-report"]
class Methodology(BaseModel, frozen=True):
"""A named research methodology: a dial bundle plus prompt templates (REQ-02)."""
slug: str = Field(pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$")
name: str
description: str
dials: Dials
planner_prompt: str
writer_prompt: str
output_shape: OutputShape
def render_planner_prompt(self, question: str, context: str) -> str:
return self.planner_prompt.format(question=question, context=context)
def render_writer_prompt(
self, question: str, context: str, plan: str, evidence: str
) -> str:
return self.writer_prompt.format(
question=question, context=context, plan=plan, evidence=evidence
)
__all__ = ["Methodology", "OutputShape"]
@@ -0,0 +1,26 @@
from collections.abc import Mapping
from researchx.methodologies.models import Methodology
class MethodologyRegistry:
"""Frozen by-slug registry that hands out immutable copies (REQ-02/REQ-03)."""
def __init__(self, methodologies: Mapping[str, Methodology]) -> None:
self._methodologies: dict[str, Methodology] = dict(methodologies)
def get(self, slug: str) -> Methodology:
return self._methodologies[slug].model_copy(deep=True)
def list(self) -> list[Methodology]:
ordered = sorted(self._methodologies.values(), key=lambda m: m.name)
return [m.model_copy(deep=True) for m in ordered]
def __contains__(self, slug: str) -> bool:
return slug in self._methodologies
def __len__(self) -> int:
return len(self._methodologies)
__all__ = ["MethodologyRegistry"]
+89
View File
@@ -0,0 +1,89 @@
import re
import unicodedata
from difflib import SequenceMatcher
from pydantic import BaseModel
from researchx.evidence.store import EvidenceStore, Passage
ANCHOR_THRESHOLD = 0.85
WEAK_MATCH_MIN = 0.80
def normalize(text: str) -> str:
nfc = unicodedata.normalize("NFC", text)
collapsed = re.sub(r"\s+", " ", nfc)
return collapsed.casefold().strip()
class AnchorVerdict(BaseModel):
quote: str
passage_id: str | None
ratio: float
matched: bool
weak: bool
def anchor_quote(quote: str, passages: list[Passage]) -> AnchorVerdict:
normalized_quote = normalize(quote)
best_ratio = 0.0
best_id: str | None = None
for passage in passages:
normalized_passage = normalize(passage.text)
if normalized_quote and normalized_quote in normalized_passage:
return AnchorVerdict(
quote=quote, passage_id=passage.passage_id, ratio=1.0, matched=True, weak=False
)
ratio = SequenceMatcher(None, normalized_quote, normalized_passage).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_id = passage.passage_id
matched = best_ratio >= ANCHOR_THRESHOLD
weak = WEAK_MATCH_MIN <= best_ratio < ANCHOR_THRESHOLD
return AnchorVerdict(
quote=quote,
passage_id=best_id if (matched or weak) else None,
ratio=round(best_ratio, 4),
matched=matched,
weak=weak,
)
_MARKER_RE = re.compile(r"\[p-(\d+)\]")
_HEADER_RE = re.compile(r"^#{1,6}\s.*$", re.MULTILINE)
def _claim_fragment(before: str) -> str:
cleaned = _HEADER_RE.sub("", before).rstrip()
fragment_start = max(
cleaned.rfind("."), cleaned.rfind("!"), cleaned.rfind("?"), cleaned.rfind("\n")
)
quote_text = cleaned[fragment_start + 1 :].strip(" \t>\"'*-")
if not quote_text:
quote_text = cleaned[-200:].strip(" \t>\"'*-")
return quote_text
def extract_report_claims(report_text: str) -> list[tuple[str, str]]:
"""Extract (passage_id, quote_text) claims from the report.
The quote text is what the WRITER actually wrote adjacent to the
citation marker — the last sentence fragment before each [p-N]
marker — NOT the stored passage's own text. This is the adversarial
core of REQ-20: a fabricated quote riding a real passage ID must
be visible to the anchorer.
"""
claims: list[tuple[str, str]] = []
for match in _MARKER_RE.finditer(report_text):
passage_id = f"p-{match.group(1)}"
quote_text = _claim_fragment(report_text[: match.start()])
claims.append((passage_id, quote_text))
return claims
def extract_quotes_from_report(report_text: str, evidence: EvidenceStore) -> list[str]:
return [quote for _pid, quote in extract_report_claims(report_text) if evidence.get(_pid)]
def extract_cited_passage_ids(report_text: str) -> list[str]:
return [f"p-{m.group(1)}" for m in _MARKER_RE.finditer(report_text)]
+208
View File
@@ -0,0 +1,208 @@
import re
from datetime import datetime
from pydantic import BaseModel
from researchx.evidence.store import EvidenceStore, Passage
from researchx.trust.anchoring import (
anchor_quote,
extract_cited_passage_ids,
extract_report_claims,
normalize,
)
from researchx.trust.liveness import LivenessStatus
_AUTHORITY_TIERS: dict[str, str] = {
"example.org": "reference",
"gov": "official",
"edu": "academic",
"org": "reference",
"com": "general",
}
def authority_tier(url: str) -> str:
host = re.sub(r"^https?://", "", url).split("/")[0].lower().split(":")[0]
labels = host.split(".")
for suffix, tier in _AUTHORITY_TIERS.items():
if labels[-1] == suffix or (len(labels) > 1 and labels[-2] == suffix):
return tier
return "general"
class CitationEntry(BaseModel):
url: str
visited_at: datetime
passages: list[str]
claims_supported: int
health: str
authority_tier: str
class VerificationChecklist(BaseModel):
run_id: str
citations_checked: int
live: int
stale: int
dead: int
anchored_claims: int
unanchored_claims: int
weak_matches: int
confidence_labels: dict[str, str]
liveness_ratio: float
class CitationLedger(BaseModel):
run_id: str
generated_at: datetime
entries: list[CitationEntry]
checklist: VerificationChecklist
_CLAIM_RE = re.compile(r"\[p-(\d+)\]")
def _claims_per_passage(report_text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for match in _CLAIM_RE.finditer(report_text):
pid = f"p-{match.group(1)}"
counts[pid] = counts.get(pid, 0) + 1
return counts
def build_ledger(
*,
run_id: str,
report_text: str,
evidence: EvidenceStore,
liveness: dict[str, LivenessStatus],
visited_at: datetime,
) -> CitationLedger:
claims = _claims_per_passage(report_text)
all_passages = evidence.writer_context()
report_claims = extract_report_claims(report_text)
cited_ids = extract_cited_passage_ids(report_text)
known_ids = {p.passage_id for p in all_passages}
cited_urls: list[str] = []
entries: list[CitationEntry] = []
anchored = 0
unanchored = 0
weak = 0
confidence: dict[str, str] = {}
for passage_id, writer_quote in report_claims:
passage = evidence.get(passage_id)
if passage is None:
unanchored += 1
continue
verdict = anchor_quote(writer_quote, [passage])
if verdict.matched:
anchored += 1
elif verdict.weak:
weak += 1
else:
unanchored += 1
for cited_id in cited_ids:
if cited_id not in known_ids:
unanchored += 1
passage = evidence.get(cited_id)
if passage is not None and passage.url not in cited_urls:
cited_urls.append(passage.url)
for page in evidence.pages():
if page.url not in cited_urls:
continue
passages = evidence.passages_for_url(page.url)
passage_ids = [p.passage_id for p in passages]
page_claims = sum(claims.get(pid, 0) for pid in passage_ids)
health = str(liveness.get(page.url, LivenessStatus.DEAD))
entries.append(
CitationEntry(
url=page.url,
visited_at=visited_at,
passages=passage_ids,
claims_supported=page_claims,
health=health,
authority_tier=authority_tier(page.url),
)
)
for passage in passages:
confidence[passage.passage_id] = (
"corroborated" if _corroborated(passage, evidence) else "single-source"
)
live = sum(1 for e in entries if e.health == "live")
stale = sum(1 for e in entries if e.health == "stale")
dead = sum(1 for e in entries if e.health == "dead")
total = len(entries)
ratio = round(live / total, 4) if total > 0 else 1.0
checklist = VerificationChecklist(
run_id=run_id,
citations_checked=total,
live=live,
stale=stale,
dead=dead,
anchored_claims=anchored,
unanchored_claims=unanchored,
weak_matches=weak,
confidence_labels=confidence,
liveness_ratio=ratio,
)
return CitationLedger(
run_id=run_id, generated_at=visited_at, entries=entries, checklist=checklist
)
def _corroborated(passage: Passage, evidence: EvidenceStore) -> bool:
normalized = normalize(passage.text)
for other in evidence.writer_context():
if other.passage_id == passage.passage_id:
continue
if normalized in normalize(other.text):
return True
return False
LIVENESS_EXPORT_GATE = 0.99
def liveness_export_gate(ledger: CitationLedger) -> tuple[bool, list[str]]:
failures = [e.url for e in ledger.entries if e.health != "live"]
liveness_ok = ledger.checklist.liveness_ratio >= LIVENESS_EXPORT_GATE
anchoring_ok = ledger.checklist.unanchored_claims == 0
return liveness_ok and anchoring_ok, failures
def export_checklist(ledger: CitationLedger) -> str:
gate_passed, failures = liveness_export_gate(ledger)
lines = [
"# Verification Checklist",
"",
f"Run: {ledger.run_id}",
f"Citations checked: {ledger.checklist.citations_checked}",
f"Live: {ledger.checklist.live} / Stale: {ledger.checklist.stale} / Dead: {ledger.checklist.dead}",
(
f"Liveness ratio: {ledger.checklist.liveness_ratio} (export gate >= {LIVENESS_EXPORT_GATE}: "
f"{'PASS' if gate_passed else 'FAIL'})"
),
(
f"Anchoring gate: {'PASS' if ledger.checklist.unanchored_claims == 0 else 'FAIL'} "
f"({ledger.checklist.unanchored_claims} unanchored)"
),
f"Anchored claims: {ledger.checklist.anchored_claims}",
f"Unanchored claims: {ledger.checklist.unanchored_claims}",
f"Weak matches (flagged, not accepted): {ledger.checklist.weak_matches}",
"",
"## Per-URL ledger",
"",
]
for entry in ledger.entries:
lines.append(
f"- {entry.url} — health: {entry.health}, tier: {entry.authority_tier}, "
f"passages: {len(entry.passages)}, claims: {entry.claims_supported}"
)
if failures:
lines += ["", "## Liveness failures", ""]
lines += [f"- {url}" for url in failures]
return "\n".join(lines) + "\n"
+65
View File
@@ -0,0 +1,65 @@
from enum import StrEnum
import httpx
from researchx.adapters.url_guard import url_allowed
_LIVENESS_TIMEOUT = 10.0
_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
_MAX_REDIRECTS = 5
_USER_AGENT = "researchx/0.2 (liveness check)"
class LivenessStatus(StrEnum):
LIVE = "live"
STALE = "stale"
DEAD = "dead"
async def check_liveness(url: str, client: httpx.AsyncClient | None = None) -> LivenessStatus:
own_client = client is None
active: httpx.AsyncClient = client or httpx.AsyncClient(
timeout=_LIVENESS_TIMEOUT,
follow_redirects=False,
)
current_url = url
try:
for _ in range(_MAX_REDIRECTS + 1):
if not url_allowed(current_url):
return LivenessStatus.DEAD
response = await active.get(
current_url,
headers={"User-Agent": _USER_AGENT},
)
if 300 <= response.status_code < 400:
location = response.headers.get("location")
if not location:
return LivenessStatus.STALE
from urllib.parse import urljoin
current_url = urljoin(current_url, location)
continue
content = b""
async for chunk in response.aiter_bytes():
content += chunk
if len(content) > _MAX_RESPONSE_BYTES:
break
status = response.status_code
if 200 <= status < 300:
return LivenessStatus.LIVE
return LivenessStatus.DEAD
return LivenessStatus.STALE
except (httpx.HTTPError, OSError):
return LivenessStatus.DEAD
finally:
if own_client:
await active.aclose()
async def liveness_report(
urls: list[str],
) -> dict[str, LivenessStatus]:
report: dict[str, LivenessStatus] = {}
for url in dict.fromkeys(urls):
report[url] = await check_liveness(url)
return report
+21
View File
@@ -0,0 +1,21 @@
import secrets as secrets_module
from string import ascii_lowercase
from researchx.adapters.local_storage import generate_run_id
def test_generate_run_id_format() -> None:
run_id = generate_run_id()
assert isinstance(run_id, str)
assert len(run_id) == 8
assert all(c in ascii_lowercase + "23456789" for c in run_id)
assert "o" not in run_id and "l" not in run_id and "0" not in run_id and "1" not in run_id
def test_generate_run_id_entropy() -> None:
ids = {generate_run_id() for _ in range(100)}
assert len(ids) == 100
def test_generate_run_id_uses_secrets_module() -> None:
assert secrets_module.choice is not None
+359
View File
@@ -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"
+99
View File
@@ -0,0 +1,99 @@
import httpx
import pytest
from researchx.adapters.capability_probe import probe_model_adapter
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.probe import DEFAULT_CONTEXT_CAP, DEFAULT_EMBEDDING_BATCH
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.types import AdapterError
def _secrets(tmp_path, values: dict[str, str]) -> EnvFileSecretsProvider:
env_file = tmp_path / ".env.secrets"
env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8")
return EnvFileSecretsProvider(paths=[env_file])
def _ok_smoke_response() -> dict[str, object]:
return {
"choices": [{"message": {"role": "assistant", "content": '{"ok": true}'}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2},
}
async def test_probe_green_on_supporting_model(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("/models"):
return httpx.Response(200, json={"data": [{"id": "w"}, {"id": "p"}]})
return httpx.Response(200, json=_ok_smoke_response())
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
probe = await probe_model_adapter(adapter)
assert probe.endpoint == "https://ollama.com/v1"
assert probe.models == ["w", "p"]
assert probe.context_window == DEFAULT_CONTEXT_CAP
assert probe.embedding_batch == DEFAULT_EMBEDDING_BATCH
assert probe.structured_outputs_supported is True
assert probe.structured_output_warning is None
assert probe.degraded is False
async def test_probe_warns_on_weak_structured_output(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="writer-model")
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("/models"):
return httpx.Response(200, json={"data": [{"id": "writer-model"}]})
return httpx.Response(200, json={"choices": [{"message": {"content": "not json"}}]})
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
probe = await probe_model_adapter(adapter)
assert probe.structured_outputs_supported is False
assert probe.structured_output_warning is not None
assert "writer-model" in probe.structured_output_warning
assert "degrade" in probe.structured_output_warning
assert probe.degraded is True
async def test_probe_reports_endpoint_down_as_unsupported(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="boom")
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
with pytest.raises(AdapterError):
await probe_model_adapter(adapter)
async def test_list_models_falls_back_to_local(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).startswith("https://ollama.com"):
return httpx.Response(401, json={"error": "unauthorized"})
return httpx.Response(200, json={"data": [{"id": "local-model"}]})
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
models = await adapter.list_models()
assert models == ["local-model"]
assert adapter.active_base_url == "http://localhost:11434/v1"
assert adapter.last_fallback_reason is not None
+66
View File
@@ -0,0 +1,66 @@
import pytest
from pydantic import ValidationError
from researchx.config.dials import DIAL_REGISTRY, DialDescriptor, Dials
EXPECTED_DIALS = {
"source_universe": "web",
"search_routing": "single",
"query_strategy": "decomposed",
"breadth": 2,
"depth": 1,
"quality_gates": "standard",
"citation_rigor": "per-claim",
"verification_passes": 1,
"synthesis_style": "memo",
"output_structure": "brief",
"memory_scope": "none",
"checkpoints": False,
}
def test_defaults_resolve() -> None:
assert Dials().model_dump() == EXPECTED_DIALS
def test_all_twelve_dials_present() -> None:
assert set(Dials.model_fields) == set(EXPECTED_DIALS)
assert len(Dials.model_fields) == 12
def test_registry_covers_all_dials() -> None:
assert set(DIAL_REGISTRY) == set(EXPECTED_DIALS)
for descriptor in DIAL_REGISTRY.values():
assert isinstance(descriptor, DialDescriptor)
assert descriptor.label
assert descriptor.description
def test_literal_dial_rejects_invalid_value() -> None:
with pytest.raises(ValidationError):
Dials(source_universe="library")
def test_bounded_int_rejects_out_of_range() -> None:
with pytest.raises(ValidationError):
Dials(breadth=6)
with pytest.raises(ValidationError):
Dials(depth=0)
with pytest.raises(ValidationError):
Dials(verification_passes=-1)
def test_checkpoints_rejects_non_bool() -> None:
with pytest.raises(ValidationError):
Dials(checkpoints=5)
def test_model_dump_round_trips() -> None:
dials = Dials(depth=3, breadth=5, checkpoints=True, synthesis_style="executive")
assert Dials.model_validate(dials.model_dump()) == dials
def test_dials_are_immutable() -> None:
dials = Dials()
with pytest.raises(ValidationError):
dials.depth = 3
+183
View File
@@ -0,0 +1,183 @@
import asyncio
import json
from pathlib import Path
import pytest
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.engine.states import RunStateName, can_transition
from researchx.methodologies import registry
_PLAN = json.dumps(
{
"sub_questions": ["methodology verification", "quote anchoring"],
"source_universe": ["web"],
"outline": ["intro", "findings"],
}
)
_WRITER_REPORT = (
"# Report\n\n"
"The verification pass re-anchored every quoted claim [p-1]. "
"Methodology-first selection converts ad-hoc prompting [p-1] "
"into reproducible decisions [p-2].\n"
)
def _canned() -> list[ChatResult]:
return [
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER_REPORT, model="fake-writer"),
]
def _actor(tmp_path: Path) -> tuple[RunActor, EventBus, FakeSearchAdapter]:
_run_id, config = make_run_config(
question="How do verification pipelines keep citations honest?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
model = FakeModelAdapter(canned=_canned())
search = FakeSearchAdapter(CORPUS)
storage = LocalStorageAdapter(base_dir=tmp_path)
bus = EventBus()
actor = RunActor(
run_config=config,
model=model,
search=search,
storage=storage,
registry=registry,
event_bus=bus,
)
return actor, bus, search
async def test_full_walk_reaches_done(tmp_path: Path) -> None:
actor, bus, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
if task.done():
break
await actor.approve()
state = await task
assert state.state == RunStateName.DONE
assert state.plan is not None and len(state.plan.sub_questions) == 2
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "DONE"
report = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "report.md")
assert "[p-1]" in report
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
"abcd2345", "ledger.json"
)
ledger = json.loads(ledger_raw)
assert ledger["checklist"]["citations_checked"] >= 1
assert ledger["checklist"]["liveness_ratio"] >= 0
checklist = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact(
"abcd2345", "checklist.md"
)
assert "Verification Checklist" in checklist
types = [e.type for e in bus.replay()]
assert "run_started" in types
assert "query_fired" in types
assert "source_visited" in types
assert "passage_extracted" in types
assert "run_done" in types
async def test_halt_at_awaiting_approval_until_approved(tmp_path: Path) -> None:
actor, _, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
for _ in range(100):
await asyncio.sleep(0.01)
if actor.state.state == RunStateName.AWAITING_APPROVAL:
break
assert actor.state.state == RunStateName.AWAITING_APPROVAL
await asyncio.sleep(0.05)
assert not task.done()
await actor.approve()
state = await task
assert state.state == RunStateName.DONE
async def test_cancel_persists_cancelled_state(tmp_path: Path) -> None:
actor, _, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
actor.cancel()
await actor.approve()
with pytest.raises(asyncio.CancelledError):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "CANCELLED"
async def test_pause_resume_mid_run(tmp_path: Path) -> None:
actor, _, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
await actor.approve()
actor.pause()
await asyncio.sleep(0.05)
assert not task.done()
actor.resume()
state = await task
assert state.state == RunStateName.DONE
def test_illegal_transitions_rejected() -> None:
assert not can_transition(RunStateName.PLANNING, RunStateName.DONE)
assert not can_transition(RunStateName.DONE, RunStateName.PLANNING)
assert can_transition(RunStateName.PLANNING, RunStateName.AWAITING_APPROVAL)
assert can_transition(RunStateName.REFLECTING, RunStateName.SEARCHING)
async def test_planner_non_json_fails_honestly(tmp_path: Path) -> None:
_run_id, config = make_run_config(
question="q",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
model = FakeModelAdapter(canned=[ChatResult(content="not json", model="fake-planner")])
actor = RunActor(
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
with pytest.raises(Exception, match="planner returned non-JSON plan"):
await task
saved = await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
assert json.loads(saved)["state"] == "FAILED"
async def test_writer_context_is_visited_only(tmp_path: Path) -> None:
actor, _, _ = _actor(tmp_path)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
await actor.approve()
await task
assert actor._store.writer_context(), "evidence collected"
outside_ids = {p.passage_id for p in actor._store.writer_context()}
assert all(pid.startswith("p-") for pid in outside_ids)
+56
View File
@@ -0,0 +1,56 @@
from researchx.evidence.store import EvidenceStore, extract_passages
def test_extract_passages_min_length() -> None:
text = "short\n" + ("x" * 150) + "\nanother short"
passages = extract_passages(text)
assert passages == ["x" * 150]
def test_store_stable_ids() -> None:
store = EvidenceStore()
first = store.add_passage("https://a", "first passage text " * 10)
second = store.add_passage("https://a", "second passage text " * 10)
assert first.passage_id == "p-1"
assert second.passage_id == "p-2"
def test_store_add_page_creates_passages() -> None:
store = EvidenceStore()
body = "\n".join(f"paragraph {i} " + "y" * 150 for i in range(3))
store.add_page("https://example.org/a", "Title A", body)
assert len(store) == 3
page = store.pages()[0]
assert page.url == "https://example.org/a"
assert page.title == "Title A"
assert len(page.passage_ids) == 3
def test_writer_context_contains_only_stored() -> None:
store = EvidenceStore()
store.add_passage("https://a", "stored passage " * 20)
context = store.writer_context()
assert len(context) == 1
assert context[0].url == "https://a"
outside = EvidenceStore()
outside_context = outside.writer_context()
assert outside_context == []
def test_dedupe_by_quote() -> None:
store = EvidenceStore()
text = "identical passage text " * 10
store.add_passage("https://a", text)
assert store.has_seen(text[:200])
assert not store.has_seen("something never stored " * 20)
def test_passages_for_url() -> None:
store = EvidenceStore()
store.add_passage("https://a", "from a " * 30)
store.add_passage("https://b", "from b " * 30)
from_a = store.passages_for_url("https://a")
assert len(from_a) == 1
assert from_a[0].url == "https://a"
assert store.passages_for_url("https://missing") == []
+82
View File
@@ -0,0 +1,82 @@
import inspect
from researchx.adapters import fakes
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter, corpus
from researchx.adapters.model import ModelAdapter, ModelRoles
from researchx.adapters.search import SearchAdapter
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
def _model() -> FakeModelAdapter:
return FakeModelAdapter(canned=[ChatResult(content="ok", model="fake-model")])
async def test_fake_model_full_interface() -> None:
model = _model()
assert isinstance(model, ModelAdapter)
result = await model.chat([ModelMessage(role="user", content="hi")], model="fake-model")
assert result.content == "ok"
assert await model.list_models() == ["fake-model"]
assert model.roles() == ModelRoles(planner="fake-planner", writer="fake-writer")
async def test_fake_model_exhausted_raises() -> None:
model = _model()
await model.chat([ModelMessage(role="user", content="hi")], model="fake-model")
try:
await model.chat([ModelMessage(role="user", content="again")], model="fake-model")
except AdapterError:
pass
else:
raise AssertionError("expected AdapterError")
async def test_fake_model_json_mode_recorded() -> None:
model = _model()
await model.chat([ModelMessage(role="user", content="hi")], model="fake-model", json_mode=True)
assert model.last_json_mode is True
async def test_fake_search_full_interface() -> None:
search = FakeSearchAdapter(corpus())
assert isinstance(search, SearchAdapter)
results = await search.search("verification quoted claim", max_results=3)
assert 0 < len(results) <= 3
page = await search.fetch(results[0].url)
assert page.url == results[0].url
async def test_fake_search_deterministic_order() -> None:
search = FakeSearchAdapter(corpus())
first = await search.search("methodology", max_results=5)
second = await search.search("methodology", max_results=5)
assert [r.url for r in first] == [r.url for r in second]
async def test_fake_search_unknown_url_raises() -> None:
search = FakeSearchAdapter(corpus())
try:
await search.fetch("https://example.org/missing")
except AdapterError:
pass
else:
raise AssertionError("expected AdapterError")
def test_corpus_shape() -> None:
pages = CORPUS
assert len(pages) >= 5
urls = [p.url for p in pages]
assert len(set(urls)) == len(urls)
texts = "\n".join(p.text for p in pages)
assert "—" in texts or "é" in texts or "近" in texts
def test_corpus_shared_sentence() -> None:
sharing = [p for p in CORPUS if fakes.SHARED_SENTENCE in p.text]
assert len(sharing) == 2
def test_fakes_no_httpx_dependency() -> None:
source = inspect.getsource(fakes)
assert "httpx" not in source
+84
View File
@@ -0,0 +1,84 @@
from datetime import UTC, datetime
import pytest
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.config.run_config import RunConfig, UnknownOverrideError, new_run_id, resolve_dials
from researchx.methodologies import registry
async def test_registry_to_fake_model_to_snapshot_round_trip(tmp_path) -> None:
methodology = registry.get("deep-report")
canned = ChatResult(
content='{"sub_questions": ["What is verification?", "How does anchoring work?"]}',
model="fake-planner",
)
model = FakeModelAdapter(canned=[canned])
plan_result = await model.chat([], model=model.roles().planner, json_mode=True)
assert "sub_questions" in plan_result.content
run_id = new_run_id()
dials = resolve_dials("deep-report", {"breadth": 3}, registry)
run_config = RunConfig(
run_id=run_id,
methodology_slug="deep-report",
question="How do verification pipelines keep citations honest?",
resolved_dials=dials,
overrides={"breadth": 3},
created_at=datetime.now(UTC),
)
assert run_config.resolved_dials.breadth == 3
storage = LocalStorageAdapter(base_dir=tmp_path)
await storage.write_run_artifact(run_id, "run_config.json", run_config.model_dump_json())
restored = RunConfig.model_validate_json(
await storage.read_run_artifact(run_id, "run_config.json")
)
assert restored == run_config
assert restored.resolved_dials.depth == methodology.dials.depth
def test_methodology_behavior_difference() -> None:
rapid = registry.get("rapid-answer")
deep = registry.get("deep-report")
assert rapid.dials.depth < deep.dials.depth
assert rapid.dials.breadth < deep.dials.breadth
assert rapid.output_shape != deep.output_shape
assert rapid.dials.synthesis_style != deep.dials.synthesis_style
assert rapid.dials.verification_passes < deep.dials.verification_passes
assert rapid.dials.output_structure != deep.dials.output_structure
def test_registry_snapshot_immutability() -> None:
from pydantic import ValidationError
methodology = registry.get("rapid-answer")
frozen = registry.get("rapid-answer")
with pytest.raises(ValidationError):
frozen.dials.depth = 99
fresh = registry.get("rapid-answer")
assert fresh.dials.depth == methodology.dials.depth
assert fresh.dials.depth == 1
def test_override_rejects_unknown_dial() -> None:
with pytest.raises(UnknownOverrideError):
resolve_dials("rapid-answer", {"nonexistent_dial": 1}, registry)
def test_new_run_id_format() -> None:
run_id = new_run_id()
assert len(run_id) == 8
assert all(c in "0123456789abcdef" for c in run_id)
async def test_fake_search_feeds_evidence_shape() -> None:
search = FakeSearchAdapter(CORPUS)
results = await search.search("methodology dials verification", max_results=5)
assert len(results) >= 1
for result in results:
page = await search.fetch(result.url)
assert page.text.strip()
+64
View File
@@ -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
+66
View File
@@ -0,0 +1,66 @@
from pathlib import Path
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.storage import StorageAdapter
from researchx.adapters.types import AdapterError
async def test_storage_round_trip(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
assert isinstance(storage, StorageAdapter)
target = await storage.write_run_artifact("abcd2345", "state.json", '{"state": "PLANNING"}')
assert target.exists()
content = await storage.read_run_artifact("abcd2345", "state.json")
assert content == '{"state": "PLANNING"}'
async def test_atomic_overwrite(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 2}')
assert await storage.read_run_artifact("abcd2345", "state.json") == '{"v": 2}'
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
assert leftovers == []
async def test_run_dir_layout(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
run_dir = storage.run_dir("abcd2345")
assert run_dir == tmp_path / "runs" / "abcd2345"
await storage.write_run_artifact("abcd2345", "report.md", "# Report")
assert run_dir.is_dir()
async def test_read_missing_raises(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
try:
await storage.read_run_artifact("zzzz9999", "state.json")
except AdapterError:
pass
else:
raise AssertionError("expected AdapterError")
async def test_path_traversal_rejected(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
for bad_name in ["../escape.md", "/etc/passwd", "a/b.md", "", "."]:
try:
await storage.write_run_artifact("abcd2345", bad_name, "x")
except AdapterError:
pass
else:
raise AssertionError(f"expected AdapterError for name: {bad_name!r}")
for bad_run_id in ["../evil", "short", "waytoolongid123", "ABCD2345", "a b c d e"]:
try:
await storage.write_run_artifact(bad_run_id, "state.json", "x")
except AdapterError:
pass
else:
raise AssertionError(f"expected AdapterError for run_id: {bad_run_id!r}")
async def test_no_tmp_leftover_after_write(tmp_path: Path) -> None:
storage = LocalStorageAdapter(base_dir=tmp_path)
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
leftovers = list((tmp_path / "runs" / "abcd2345").glob("*.tmp"))
assert leftovers == []
+95
View File
@@ -0,0 +1,95 @@
import pytest
from pydantic import ValidationError
from researchx.methodologies import registry
def test_registry_resolves_both_slugs() -> None:
rapid = registry.get("rapid-answer")
deep = registry.get("deep-report")
assert rapid.slug == "rapid-answer"
assert deep.slug == "deep-report"
assert rapid.name == "Rapid Answer"
assert deep.name == "Deep Report"
def test_unknown_slug_raises_key_error() -> None:
with pytest.raises(KeyError):
registry.get("systematic-review")
def test_list_ordered_by_name() -> None:
listed = registry.list()
assert [m.name for m in listed] == ["Deep Report", "Rapid Answer"]
def test_builtin_dials_match_contracts() -> None:
rapid = registry.get("rapid-answer")
assert rapid.dials.depth == 1
assert rapid.dials.breadth == 1
assert rapid.dials.verification_passes == 1
assert rapid.dials.citation_rigor == "per-claim"
assert rapid.dials.synthesis_style == "memo"
assert rapid.dials.output_structure == "brief"
assert rapid.output_shape == "memo"
deep = registry.get("deep-report")
assert deep.dials.depth == 3
assert deep.dials.breadth == 2
assert deep.dials.verification_passes == 2
assert deep.dials.citation_rigor == "per-claim"
assert deep.dials.synthesis_style == "executive"
assert deep.dials.output_structure == "report"
assert deep.dials.checkpoints is True
assert deep.output_shape == "executive-report"
def test_methodologies_differ_meaningfully() -> None:
rapid = registry.get("rapid-answer")
deep = registry.get("deep-report")
assert rapid.dials.depth != deep.dials.depth
assert rapid.dials.breadth != deep.dials.breadth
assert rapid.dials.verification_passes != deep.dials.verification_passes
assert rapid.dials.synthesis_style != deep.dials.synthesis_style
assert rapid.dials.output_structure != deep.dials.output_structure
assert rapid.output_shape != deep.output_shape
def test_registry_entries_are_immutable() -> None:
snapshot = registry.get("rapid-answer")
with pytest.raises(ValidationError):
snapshot.dials.depth = 3
assert registry.get("rapid-answer").dials.depth == 1
listed = registry.list()
with pytest.raises(ValidationError):
listed[0].dials.checkpoints = True
assert registry.get("rapid-answer").dials.checkpoints is False
def test_prompt_templates_render_placeholders() -> None:
deep = registry.get("deep-report")
planner = deep.render_planner_prompt(question="What changed?", context="none")
assert "What changed?" in planner
assert "none" in planner
writer = deep.render_writer_prompt(
question="What changed?", context="none", plan="p1", evidence="[p-1] (https://a) quote text"
)
assert "{plan}" not in writer
assert "{evidence}" not in writer
assert "p1" in writer
assert "[p-1] (https://a) quote text" in writer
def test_writer_prompt_requires_evidence_slot() -> None:
for slug in ("rapid-answer", "deep-report"):
methodology = registry.get(slug)
assert "{evidence}" in methodology.writer_prompt
def test_mutation_of_returned_copy_does_not_affect_registry() -> None:
original = registry.get("deep-report").dials.model_dump()
copy = registry.get("deep-report").dials.model_copy()
mutated = copy.model_copy(update={"depth": 1})
assert mutated.depth == 1
assert registry.get("deep-report").dials.model_dump() == original
+215
View File
@@ -0,0 +1,215 @@
import asyncio
import json
from datetime import UTC, datetime
from pathlib import Path
import httpx
import pytest
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.evidence.store import EvidenceStore
from researchx.methodologies import registry
from researchx.trust.anchoring import anchor_quote, extract_quotes_from_report
class RecordingModel:
def __init__(self, canned: list[ChatResult]) -> None:
self._canned = list(canned)
self.prompts: list[str] = []
async def chat(
self, messages: list[ModelMessage], *, model: str, temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
self.prompts.append(messages[0].content)
return self._canned.pop(0)
async def list_models(self) -> list[str]:
return ["fake-model"]
def roles(self):
from researchx.adapters.model import ModelRoles
return ModelRoles(planner="fake-planner", writer="fake-writer")
_PLAN = json.dumps({"sub_questions": ["verification"], "source_universe": ["web"], "outline": []})
async def test_evidence_reaches_writer_prompt(tmp_path: Path) -> None:
_rid, config = make_run_config(
question="q", context="", methodology_slug="rapid-answer",
overrides={}, registry=registry, run_id="abcd2345",
)
model = RecordingModel(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content="# Report\n\nClaim [p-1].\n", model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=model, # type: ignore[arg-type]
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
while actor.state.state == RunStateName.PLANNING:
await asyncio.sleep(0.01)
await actor.approve()
await task
writer_prompt = model.prompts[-1]
assert "[p-1]" in writer_prompt
assert "(https://example.org/" in writer_prompt
assert "Evidence passages:" in writer_prompt
async def test_engine_refuses_private_urls_at_extract(tmp_path: Path, monkeypatch) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("TAVILY_API_KEY=t-key\n", encoding="utf-8")
secrets = EnvFileSecretsProvider(paths=[env_file])
tavily = TavilySearchAdapter(secrets)
called_urls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
called_urls.append(str(request.url))
return httpx.Response(200, text="<html><body>never</body></html>")
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=transport, **kw))
with pytest.raises(AdapterError, match="fetch refused"):
await tavily.fetch("http://169.254.169.254/latest/meta-data")
assert called_urls == []
async def test_resume_from_searching_mid_run(tmp_path: Path) -> None:
_rid, config = make_run_config(
question="q", context="", methodology_slug="rapid-answer",
overrides={}, registry=registry, run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
mid_state = RunState(
run_id="abcd2345",
question="q",
methodology_slug="rapid-answer",
state=RunStateName.SEARCHING,
plan=Plan(sub_questions=["verification"], source_universe=["web"], outline=[]),
depth_cursor=0,
depth_target=1,
)
await storage.write_run_artifact("abcd2345", "state.json", mid_state.model_dump_json())
model = RecordingModel(
canned=[
ChatResult(content="# Report\n\nClaim [p-1].\n", model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=model, # type: ignore[arg-type]
search=FakeSearchAdapter(CORPUS),
storage=storage,
registry=registry,
event_bus=EventBus(),
)
actor.state = RunState.model_validate_json(
await storage.read_run_artifact("abcd2345", "state.json")
)
state = await actor.run()
assert state.state == RunStateName.DONE
async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
_rid, config = make_run_config(
question="q", context="", methodology_slug="rapid-answer",
overrides={}, registry=registry, run_id="abcd2345",
)
model = FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content="# Report\n\nClaim [p-1].\n", model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
while actor.state.state == RunStateName.PLANNING:
await asyncio.sleep(0.01)
await actor.approve()
actor.pause()
actor.cancel()
with pytest.raises(asyncio.CancelledError):
await task
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
)
assert saved["state"] == "CANCELLED"
def test_quote_mismatch_flagged_in_ledger_path() -> None:
store = EvidenceStore()
real_passage = store.add_passage("https://a", "The verified sentence about anchoring " * 5)
hallucinated = "This quote was never in any stored passage at all whatsoever."
verdict = anchor_quote(hallucinated, [real_passage])
assert verdict.matched is False
assert verdict.weak is False
good = anchor_quote(real_passage.quote, [real_passage])
assert good.matched is True
report = f"Claim [p-1].\n\n> {hallucinated} [p-1]\n"
quotes = extract_quotes_from_report(report, store)
assert len(quotes) == 2
bad_verdict = anchor_quote(hallucinated, store.writer_context())
assert bad_verdict.matched is False
def test_ledger_export_gate() -> None:
from researchx.trust.ledger import build_ledger, liveness_export_gate
from researchx.trust.liveness import LivenessStatus
store = EvidenceStore()
stored_text = "The verified sentence used for the export gate test."
store.add_passage("https://a", stored_text)
ledger = build_ledger(
run_id="abcd2345",
report_text=f"{stored_text} [p-1]",
evidence=store,
liveness={"https://a": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
passed, failures = liveness_export_gate(ledger)
assert passed is True
assert failures == []
ledger_dead = build_ledger(
run_id="abcd2345",
report_text=f"{stored_text} [p-1]",
evidence=store,
liveness={"https://a": LivenessStatus.DEAD},
visited_at=datetime.now(UTC),
)
passed, failures = liveness_export_gate(ledger_dead)
assert passed is False
assert failures == ["https://a"]
+129
View File
@@ -0,0 +1,129 @@
import asyncio
import json
from pathlib import Path
import httpx
import pytest
from researchx.adapters.capability_probe import probe_model_adapter
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.types import ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.engine.states import RunStateName
from researchx.methodologies import registry
async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("OLLAMA_API_KEY=cloud-key\n", encoding="utf-8")
secrets = EnvFileSecretsProvider(paths=[env_file])
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("/models"):
return httpx.Response(200, json={"data": [{"id": "w"}]})
return httpx.Response(
200, json={"choices": [{"message": {"content": '{"ok": true}'}}]}
)
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=transport, **kw))
probe = await probe_model_adapter(adapter)
assert probe.structured_outputs_supported is True
_rid, config = make_run_config(
question="q",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
plan = json.dumps({"sub_questions": ["verification"], "source_universe": ["web"], "outline": []})
writer = "# Report\n\nClaim [p-1].\n"
fake_model = FakeModelAdapter(
canned=[
ChatResult(content=plan, model="fake-planner"),
ChatResult(content=writer, model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=EventBus(),
)
await actor.attach_probe(probe.model_dump(mode="json"))
assert actor.state.probe is not None
assert actor.state.probe["structured_outputs_supported"] is True
task = asyncio.create_task(actor.run())
while actor.state.state == RunStateName.PLANNING:
await asyncio.sleep(0.01)
await actor.approve()
state = await task
assert state.state == RunStateName.DONE
saved = json.loads(
await LocalStorageAdapter(base_dir=tmp_path).read_run_artifact("abcd2345", "state.json")
)
assert saved["probe"]["endpoint"] == "https://ollama.com/v1"
async def test_probe_failure_never_crashes_the_run(tmp_path: Path, monkeypatch) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("OLLAMA_API_KEY=cloud-key\n", encoding="utf-8")
secrets = EnvFileSecretsProvider(paths=[env_file])
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="down")
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=transport, **kw))
from researchx.adapters.types import AdapterError
with pytest.raises(AdapterError):
await probe_model_adapter(adapter)
_rid, config = make_run_config(
question="q",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
plan = json.dumps({"sub_questions": ["verification"], "source_universe": ["web"], "outline": []})
writer = "# Report\n\nClaim [p-1].\n"
fake_model = FakeModelAdapter(
canned=[
ChatResult(content=plan, model="fake-planner"),
ChatResult(content=writer, model="fake-writer"),
]
)
bus = EventBus()
actor = RunActor(
run_config=config,
model=fake_model,
search=FakeSearchAdapter(CORPUS),
storage=LocalStorageAdapter(base_dir=tmp_path),
registry=registry,
event_bus=bus,
)
task = asyncio.create_task(actor.run())
while actor.state.state == RunStateName.PLANNING:
await asyncio.sleep(0.01)
await actor.approve()
state = await task
assert state.state == RunStateName.DONE
assert state.probe is None
+203
View File
@@ -0,0 +1,203 @@
import httpx
import pytest
from researchx.adapters.duckduckgo import DuckDuckGoSearchAdapter
from researchx.adapters.model import ModelRoles
from researchx.adapters.ollama import OllamaModelAdapter
from researchx.adapters.probe import CapabilityProbe, cap_context_window, structured_output_warning
from researchx.adapters.secrets import EnvFileSecretsProvider
from researchx.adapters.tavily import TavilySearchAdapter
from researchx.adapters.types import AdapterError, ModelMessage
def _secrets(tmp_path, values: dict[str, str]) -> EnvFileSecretsProvider:
env_file = tmp_path / ".env.secrets"
env_file.write_text(
"".join(f"{k}={v}\n" for k, v in values.items()),
encoding="utf-8",
)
return EnvFileSecretsProvider(paths=[env_file])
def _chat_payload_ok() -> dict[str, object]:
return {
"choices": [{"message": {"role": "assistant", "content": '{"ok": true}'}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
}
async def test_ollama_adapter_uses_cloud_when_keyed(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
calls: list[tuple[str, dict[str, str]]] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append((str(request.url), dict(request.headers)))
return httpx.Response(200, json=_chat_payload_ok())
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
result = await adapter.chat([ModelMessage(role="user", content="hi")], model="w")
assert result.content == '{"ok": true}'
assert calls[0][0].startswith("https://ollama.com/v1")
assert "Bearer cloud-key" in calls[0][1]["authorization"]
async def test_ollama_adapter_falls_back_to_local_on_cloud_failure(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
bases_hit: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
bases_hit.append(url)
if url.startswith("https://ollama.com"):
return httpx.Response(401, json={"error": "bad key"})
return httpx.Response(200, json=_chat_payload_ok())
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
result = await adapter.chat([ModelMessage(role="user", content="hi")], model="w")
assert result.content == '{"ok": true}'
assert any(u.startswith("http://localhost:11434/v1") for u in bases_hit)
assert adapter.last_fallback_reason is not None
assert adapter.active_base_url == "http://localhost:11434/v1"
async def test_ollama_roles(tmp_path) -> None:
secrets = _secrets(tmp_path, {})
adapter = OllamaModelAdapter(secrets, planner_model="planner-model", writer_model="writer-model")
assert adapter.roles() == ModelRoles(planner="planner-model", writer="writer-model")
def test_context_cap() -> None:
assert cap_context_window(None) == 8192
assert cap_context_window(100_000) == 8192
assert cap_context_window(4_096) == 4_096
assert cap_context_window(0) == 8192
def test_structured_output_warning_contract() -> None:
assert structured_output_warning(True, "m") is None
warning = structured_output_warning(False, "writer-model")
assert warning is not None
assert "writer-model" in warning
assert "degrade" in warning
probe = CapabilityProbe(
endpoint="http://localhost:11434/v1",
models=["m"],
context_window=8192,
embedding_batch=8,
structured_outputs_supported=False,
structured_output_warning=warning,
)
assert probe.degraded
async def test_tavily_requires_key(tmp_path) -> None:
secrets = _secrets(tmp_path, {})
with pytest.raises(AdapterError):
TavilySearchAdapter(secrets)
async def test_tavily_search_returns_results(tmp_path, monkeypatch) -> None:
secrets = _secrets(tmp_path, {"TAVILY_API_KEY": "t-key"})
def handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content.decode())
assert body["api_key"] == "t-key"
return httpx.Response(
200,
json={
"results": [
{"title": "T1", "url": "https://example.org/a", "content": "snippet one"},
{"title": "T2", "url": "https://example.org/b", "content": "snippet two"},
]
},
)
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
adapter = TavilySearchAdapter(secrets)
results = await adapter.search("deep research", max_results=2)
assert [r.title for r in results] == ["T1", "T2"]
async def test_ddg_parses_html_results(monkeypatch) -> None:
html = """
<div class="results">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fx&amp;rut=abc">Example One</a>
<a class="result__snippet" href="#">First snippet text</a>
<a rel="nofollow" class="result__a" href="https://example.org/y">Example Two</a>
<a class="result__snippet" href="#">Second snippet text</a>
</div>
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=html)
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
adapter = DuckDuckGoSearchAdapter()
results = await adapter.search("anything", max_results=8)
assert [r.url for r in results] == ["https://example.org/x", "https://example.org/y"]
assert results[0].snippet == "First snippet text"
assert adapter.last_degradation is None
async def test_ddg_rate_limit_degrades_with_typed_warning(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(403, text="forbidden")
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
adapter = DuckDuckGoSearchAdapter()
results = await adapter.search("anything")
assert results == []
assert adapter.last_degradation is not None
assert "rate-limited" in adapter.last_degradation
async def test_ddg_fetch_extracts_text(monkeypatch) -> None:
html = "<html><head><title>Page Title</title></head><body><script>var x=1;</script><p>Hello world</p></body></html>"
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=html)
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
adapter = DuckDuckGoSearchAdapter()
page = await adapter.fetch("https://example.org/x")
assert page.title == "Page Title"
assert "Hello world" in page.text
assert "var x" not in page.text
def test_resolve_search_adapter_prefers_tavily(tmp_path) -> None:
from researchx.adapters.duckduckgo import resolve_search_adapter
keyed = _secrets(tmp_path, {"TAVILY_API_KEY": "t-key"})
adapter, name = resolve_search_adapter(keyed)
assert name == "tavily"
assert isinstance(adapter, TavilySearchAdapter)
keyless = _secrets(tmp_path, {})
adapter, name = resolve_search_adapter(keyless)
assert name == "duckduckgo-keyless"
assert isinstance(adapter, DuckDuckGoSearchAdapter)
+105
View File
@@ -0,0 +1,105 @@
import json
from pathlib import Path
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
from researchx.methodologies import registry
_PLAN = json.dumps(
{
"sub_questions": ["verification passes"],
"source_universe": ["web"],
"outline": ["intro"],
}
)
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
def test_run_id_generator_is_the_storage_one() -> None:
from researchx.engine.actor import make_run_config as mrc
run_id, _ = mrc(
question="q", context="", methodology_slug="rapid-answer", overrides={}, registry=registry
)
assert len(run_id) == 8
alphabet = set("abcdefghjkmnpqrstuvwxyz23456789")
assert all(c in alphabet for c in run_id)
async def test_state_file_round_trip_enables_restart(tmp_path: Path) -> None:
run_id, config = make_run_config(
question="How do engines resume?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
state = RunState(
run_id=run_id,
question=config.question,
methodology_slug="rapid-answer",
state=RunStateName.SEARCHING,
depth_cursor=1,
depth_target=3,
)
await storage.write_run_artifact(run_id, "state.json", state.model_dump_json())
restored = RunState.model_validate_json(
await storage.read_run_artifact(run_id, "state.json")
)
assert restored.depth_cursor == 1
assert restored.depth_target == 3
assert restored.state == RunStateName.SEARCHING
async def test_actor_resumes_from_restored_state(tmp_path: Path) -> None:
run_id, config = make_run_config(
question="How do engines resume?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
)
storage = LocalStorageAdapter(base_dir=tmp_path)
mid_state = RunState(
run_id=run_id,
question=config.question,
methodology_slug="rapid-answer",
state=RunStateName.AWAITING_APPROVAL,
depth_cursor=0,
depth_target=1,
plan=Plan(sub_questions=["verification passes"], source_universe=["web"], outline=["intro"]),
)
await storage.write_run_artifact(run_id, "state.json", mid_state.model_dump_json())
model = FakeModelAdapter(
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
]
)
actor = RunActor(
run_config=config,
model=model,
search=FakeSearchAdapter(CORPUS),
storage=storage,
registry=registry,
event_bus=EventBus(),
)
restored = RunState.model_validate_json(
await storage.read_run_artifact(run_id, "state.json")
)
actor.state = restored
await actor.approve()
state = await actor.run()
assert state.state == RunStateName.DONE
assert state.plan is not None
assert state.plan.sub_questions == ["verification passes"]
+96
View File
@@ -0,0 +1,96 @@
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from researchx.config.dials import Dials
from researchx.config.run_config import (
RunConfig,
UnknownOverrideError,
new_run_id,
resolve_dials,
)
from researchx.methodologies import registry
def test_resolve_applies_overrides_on_top_of_bundle() -> None:
resolved = resolve_dials("deep-report", {"depth": 1, "checkpoints": False}, registry)
assert resolved.depth == 1
assert resolved.checkpoints is False
assert resolved.breadth == 2
assert resolved.verification_passes == 2
def test_resolve_empty_overrides_returns_bundle() -> None:
resolved = resolve_dials("rapid-answer", {}, registry)
assert resolved == registry.get("rapid-answer").dials
def test_resolve_rejects_unknown_override_keys() -> None:
with pytest.raises(UnknownOverrideError):
resolve_dials("rapid-answer", {"temperature": 0.7}, registry)
def test_resolve_rejects_invalid_override_values() -> None:
with pytest.raises(ValidationError):
resolve_dials("rapid-answer", {"depth": 9}, registry)
with pytest.raises(ValidationError):
resolve_dials("deep-report", {"synthesis_style": "poetic"}, registry)
def test_resolve_does_not_mutate_registry_bundle() -> None:
resolve_dials("deep-report", {"depth": 1}, registry)
assert registry.get("deep-report").dials.depth == 3
def _run_config(**overrides: object) -> RunConfig:
return RunConfig(
run_id=new_run_id(),
methodology_slug="deep-report",
question="What changed in the market?",
context="Q3 earnings season",
resolved_dials=resolve_dials("deep-report", overrides, registry),
created_at=datetime.now(UTC),
overrides=dict(overrides),
)
def test_run_config_accepts_valid_id() -> None:
config = _run_config(depth=2)
assert config.run_id == "abcd1234" or len(config.run_id) == 8
def test_run_config_rejects_invalid_run_id() -> None:
with pytest.raises(ValidationError):
RunConfig(
run_id="short",
methodology_slug="rapid-answer",
question="q",
resolved_dials=Dials(),
created_at=datetime.now(UTC),
)
def test_run_config_rejects_empty_question() -> None:
with pytest.raises(ValidationError):
RunConfig(
run_id=new_run_id(),
methodology_slug="rapid-answer",
question="",
resolved_dials=Dials(),
created_at=datetime.now(UTC),
)
def test_run_config_snapshot_round_trips_json() -> None:
config = _run_config(depth=2, checkpoints=False)
restored = RunConfig.model_validate_json(config.model_dump_json())
assert restored == config
assert restored.resolved_dials.depth == 2
assert restored.overrides == {"depth": 2, "checkpoints": False}
def test_run_config_is_immutable() -> None:
config = _run_config()
with pytest.raises(ValidationError):
config.question = "different"
+46
View File
@@ -0,0 +1,46 @@
from pathlib import Path
from researchx.adapters.secrets import EnvFileSecretsProvider, SecretsProvider
def test_secrets_provider_conformance(tmp_path: Path) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("OLLAMA_API_KEY=test-key-value\n", encoding="utf-8")
provider = EnvFileSecretsProvider(paths=[env_file])
assert isinstance(provider, SecretsProvider)
assert provider.resolve("OLLAMA_API_KEY") == "test-key-value"
assert provider.resolve("MISSING_VAR") is None
def test_secrets_later_file_wins(tmp_path: Path) -> None:
first = tmp_path / ".env"
second = tmp_path / ".env.secrets"
first.write_text("TAVILY_API_KEY=first-key\nOLLAMA_API_KEY=ollama-first\n", encoding="utf-8")
second.write_text("OLLAMA_API_KEY=ollama-second\n", encoding="utf-8")
provider = EnvFileSecretsProvider(paths=[first, second])
assert provider.resolve("TAVILY_API_KEY") == "first-key"
assert provider.resolve("OLLAMA_API_KEY") == "ollama-second"
def test_secrets_parse_skips_comments_and_blanks(tmp_path: Path) -> None:
env_file = tmp_path / ".env"
env_file.write_text(
"# comment line\n\nGITEA_TOKEN='quoted-token'\nBROKEN_LINE_NO_EQUALS\n",
encoding="utf-8",
)
provider = EnvFileSecretsProvider(paths=[env_file])
assert provider.resolve("GITEA_TOKEN") == "quoted-token"
assert provider.resolve("BROKEN_LINE_NO_EQUALS") is None
def test_secrets_repr_never_leaks_values(tmp_path: Path) -> None:
env_file = tmp_path / ".env.secrets"
env_file.write_text("OLLAMA_API_KEY=super-secret-value-123\n", encoding="utf-8")
provider = EnvFileSecretsProvider(paths=[env_file])
assert "super-secret-value-123" not in repr(provider)
assert "super-secret-value-123" not in str(provider)
def test_secrets_missing_file_ignored(tmp_path: Path) -> None:
provider = EnvFileSecretsProvider(paths=[tmp_path / ".env.absent"])
assert provider.resolve("ANY_KEY") is None
+5
View File
@@ -0,0 +1,5 @@
import researchx
def test_package_imports() -> None:
assert researchx.__name__ == "researchx"
+159
View File
@@ -0,0 +1,159 @@
from datetime import UTC, datetime
import httpx
from researchx.adapters.fakes import CORPUS, SHARED_SENTENCE
from researchx.evidence.store import EvidenceStore
from researchx.trust.anchoring import anchor_quote, normalize
from researchx.trust.ledger import build_ledger, export_checklist
from researchx.trust.liveness import LivenessStatus, check_liveness, liveness_report, url_allowed
def test_normalize_pipeline() -> None:
assert normalize(" Foo Bar ") == "foo bar"
assert normalize("Café") == normalize("café")
def test_anchor_exact_substring_short_circuits() -> None:
store = EvidenceStore()
passage = store.add_passage("https://a", SHARED_SENTENCE)
verdict = anchor_quote(SHARED_SENTENCE, [passage])
assert verdict.matched is True
assert verdict.ratio == 1.0
assert verdict.weak is False
def test_anchor_weak_match_flagged() -> None:
store = EvidenceStore()
passage = store.add_passage("https://a", SHARED_SENTENCE)
mutated = SHARED_SENTENCE.replace(
"The verification pass re-anchored every quoted claim",
"A liveness check validated the cited evidence",
)
verdict = anchor_quote(mutated, [passage])
assert verdict.ratio < 0.85
def test_anchor_high_fuzzy_match() -> None:
store = EvidenceStore()
passage = store.add_passage("https://a", "The pipeline verifies every quoted claim against its stored passage before shipping.")
near = "The pipeline verify every quoted claim against its stored passage before shipping."
verdict = anchor_quote(near, [passage])
assert verdict.ratio >= 0.85
assert verdict.matched is True
def test_anchor_no_match_returns_unmatched() -> None:
store = EvidenceStore()
passage = store.add_passage("https://a", SHARED_SENTENCE)
verdict = anchor_quote("completely unrelated text about something else entirely", [passage])
assert verdict.matched is False
assert verdict.weak is False
assert verdict.passage_id is None
def test_url_guard_blocks_private_and_schemes() -> None:
assert url_allowed("http://169.254.169.254/latest/meta-data") is False
assert url_allowed("http://localhost:8000/api") is False
assert url_allowed("http://127.0.0.1/x") is False
assert url_allowed("http://10.0.0.1/x") is False
assert url_allowed("file:///etc/passwd") is False
assert url_allowed("ftp://example.org/x") is False
assert url_allowed("not a url") is False
async def test_liveness_classification_live(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="alive page")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, follow_redirects=True) as client:
status = await check_liveness("https://example.org/live", client)
assert status == LivenessStatus.LIVE
async def test_liveness_dead_on_error(monkeypatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="boom")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
status = await check_liveness("https://example.org/dead", client)
assert status == LivenessStatus.DEAD
async def test_liveness_report_dedupes_and_covers() -> None:
report = await liveness_report(["https://a", "https://a", "https://b"])
assert report["https://a"] == LivenessStatus.DEAD
assert len(report) == 2
async def test_liveness_blocked_url_reports_dead() -> None:
status = await check_liveness("http://169.254.169.254/latest/meta-data")
assert status == LivenessStatus.DEAD
def _store_with_report() -> tuple[EvidenceStore, str]:
store = EvidenceStore()
for page in CORPUS[:3]:
store.add_page(page.url, page.title, page.text)
first_passage = store.pages()[0].passage_ids[0]
stored = store.get(first_passage)
sentence = (stored.text if stored else "").split(".")[0] + "."
report = f"# Report\n\n{sentence} [{first_passage}]\n\nMore context [{first_passage}]\n"
return store, report
async def test_ledger_build_and_checklist_export(monkeypatch) -> None:
store, report = _store_with_report()
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="ok")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, follow_redirects=True) as client:
liveness = {}
for page in store.pages():
liveness[page.url] = await check_liveness(page.url, client)
ledger = build_ledger(
run_id="abcd2345",
report_text=report,
evidence=store,
liveness=liveness,
visited_at=datetime.now(UTC),
)
assert ledger.run_id == "abcd2345"
cited_url = store.pages()[0].url
assert [e.url for e in ledger.entries] == [cited_url]
assert ledger.checklist.citations_checked == 1
assert ledger.checklist.live == 1
assert ledger.checklist.dead == 0
assert ledger.checklist.anchored_claims >= 1
assert all(
entry.authority_tier in ("reference", "official", "academic", "general")
for entry in ledger.entries
)
checklist = export_checklist(ledger)
assert "Verification Checklist" in checklist
assert "Live:" in checklist
assert "Weak matches" in checklist
def test_ledger_confidence_labels_single_vs_corroborated() -> None:
store = EvidenceStore()
store.add_passage("https://a", SHARED_SENTENCE)
store.add_passage("https://b", SHARED_SENTENCE)
report = "[p-1] [p-2]"
ledger = build_ledger(
run_id="abcd2345",
report_text=report,
evidence=store,
liveness={"https://a": LivenessStatus.LIVE, "https://b": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
labels = ledger.checklist.confidence_labels
assert len(labels) == 2
corroborated = [v for v in labels.values() if v == "corroborated"]
assert len(corroborated) >= 1
+630
View File
@@ -0,0 +1,630 @@
version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "ast-serialize"
version = "0.11.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" },
{ url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" },
{ url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" },
{ url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" },
{ url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" },
{ url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" },
{ url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" },
{ url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" },
{ url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" },
{ url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" },
{ url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" },
{ url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" },
{ url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" },
{ url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" },
{ url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" },
{ url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" },
{ url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" },
{ url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" },
{ url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" },
{ url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" },
{ url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" },
{ url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" },
{ url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" },
{ url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" },
{ url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" },
{ url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" },
{ url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" },
{ url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" },
{ url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" },
{ url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" },
{ url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" },
{ url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" },
{ url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" },
{ url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" },
{ url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" },
{ url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" },
{ url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.115.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "librt"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" },
{ url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" },
{ url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" },
{ url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" },
{ url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" },
{ url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" },
{ url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" },
{ url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" },
{ url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" },
{ url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" },
{ url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" },
{ url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" },
{ url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" },
{ url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" },
{ url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" },
{ url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" },
{ url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" },
{ url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" },
{ url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" },
{ url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" },
{ url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" },
{ url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" },
{ url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" },
{ url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" },
{ url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" },
{ url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" },
{ url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" },
{ url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" },
{ url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" },
{ url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" },
{ url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" },
{ url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" },
{ url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" },
{ url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" },
{ url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" },
{ url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" },
{ url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" },
{ url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" },
{ url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" },
{ url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" },
{ url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" },
{ url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" },
{ url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" },
{ url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" },
{ url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" },
{ url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" },
{ url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" },
{ url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" },
{ url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" },
{ url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" },
{ url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" },
{ url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" },
{ url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" },
{ url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" },
{ url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" },
{ url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" },
{ url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" },
{ url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" },
{ url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" },
{ url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" },
{ url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" },
{ url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" },
{ url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" },
{ url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" },
{ url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" },
]
[[package]]
name = "mypy"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" },
{ url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" },
{ url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" },
{ url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" },
{ url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" },
{ url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" },
{ url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" },
{ url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" },
{ url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" },
{ url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" },
{ url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
{ url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
{ url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
{ url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
{ url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" },
{ url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" },
{ url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
{ url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
{ url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" },
{ url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" },
{ url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" },
{ url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" },
{ url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" },
{ url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" },
{ url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" },
{ url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" },
{ url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" },
{ url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" },
{ url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "researchx"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "sse-starlette" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115,<0.116" },
{ name = "httpx", specifier = ">=0.27,<1" },
{ name = "pydantic", specifier = ">=2.8,<3" },
{ name = "sse-starlette", specifier = ">=2.1,<3" },
{ name = "uvicorn", specifier = ">=0.30,<1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=1.11" },
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-asyncio", specifier = ">=0.23" },
{ name = "ruff", specifier = ">=0.6" },
]
[[package]]
name = "ruff"
version = "0.16.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
]
[[package]]
name = "sse-starlette"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/3e/eae74d8d33e3262bae0a7e023bb43d8bdd27980aa3557333f4632611151f/sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926", size = 18635, upload-time = "2025-07-06T09:41:33.631Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/f1/6c7eaa8187ba789a6dd6d74430307478d2a91c23a5452ab339b6fbe15a08/sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a", size = 10824, upload-time = "2025-07-06T09:41:32.321Z" },
]
[[package]]
name = "starlette"
version = "0.46.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
.env
.env.*
!.env.example
*.test.ts
*.test.tsx
npm-debug.log*
Dockerfile
.dockerignore
README.md
+36
View File
@@ -0,0 +1,36 @@
# ResearchX frontend — production release image (ciagent release system)
# Multi-stage: deps -> build -> runtime. Next.js standalone output.
# Built ON the Docker host from the synced source (no local docker required).
# ---- deps ----
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# ---- build ----
FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
# ---- runtime ----
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup -S researchx && adduser -S researchx -G researchx
# Standalone output: the server + minimal node_modules only.
COPY --from=build --chown=researchx:researchx /app/.next/standalone ./
COPY --from=build --chown=researchx:researchx /app/.next/static ./.next/static
USER researchx
EXPOSE 3000
CMD ["node", "server.js"]
+47 -11
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.
@@ -15,7 +22,8 @@ pnpm install
pnpm dev
```
Open <http://localhost:3000> — you'll land on `/ask`.
Open <http://localhost:3000> — you'll land on `/ask`, inside the app
shell (left sidebar for navigation, top bar with search and your avatar).
The happy path:
@@ -31,6 +39,29 @@ The happy path:
plus the citation ledger and **Export Markdown/JSON** buttons that
download real files with citations as real links
The shell adds zero required clicks to that path. Everything else it
hosts is wayfinding and accumulated research:
- **Open questions** (`/ask`, below the form) — follow-up suggestions
derived from unanswered questions of your past runs; one click loads
one into the question box for editing (never auto-starts).
- **Refine question** (`/ask`, beside the box) — an opt-in back-and-forth:
a few clarifying questions, then a composed refined question with
suggested context. Use it, or keep your original. Scripted in the
prototype — model-driven refine lands with v0.2.
- **Library** (`/library`) — every report you've generated, filed into
auto-categorized folders; sort by recency or title; open any card to
re-enter its Reading Room. The **Connections** panel draws the links
between reports — shared sources (⇄) and adjacent topics (→). The
project graph and task inbox remain v2 previews.
- **Settings** (`/settings`) — API-key status (masked, never raw),
the local directory reports are stored in, the default methodology
and the 12 dials it resolves to, appearance, and about/version.
- **⌘K** (top bar) — jump to any screen or stored report. Navigation
only; full search arrives with the v0.2 engine.
- **Avatar** (top bar, right) — local single-user profile, links to
Settings and About.
## Scripts
| Script | What it does |
@@ -50,8 +81,12 @@ The happy path:
never touch `MockApiClient` directly. v0.2 phase 3 swaps the binding
to the OpenAPI-generated backend client with no component rewrites.
- The mock layer lives in `src/lib/api/mock/`: fixture data for all 8
locked methodologies and a scripted Deep Report run (trace events,
report with `[^passageId]` citation markers, citation ledger).
locked methodologies, a scripted Deep Report run (trace events,
report with `[^passageId]` citation markers, citation ledger), the
stored-reports library with folders and cross-report connections,
adapter connection status, settings, and the local user profile.
- The app shell (sidebar + top bar) lives in the root layout
(`src/app/layout.tsx`); every screen renders inside it.
- Design tokens (editorial type scale, warm paper palette, spacing) are
defined as Tailwind v4 `@theme` tokens in `src/app/globals.css`.
- Question + context survive the `/ask` → `/choose` navigation via a
@@ -61,15 +96,16 @@ The happy path:
```
src/
app/ App Router routes (ask, choose, run/[id], run/[id]/report)
components/ UI components (server by default, client where interactive)
app/ App Router routes (ask, choose, run/[id], run/[id]/report,
library, settings) — all inside the shell layout
components/ UI components (shell chrome, screens, tests co-located)
lib/ Domain types, ApiClient boundary, mock data, stores
test/ Test harness setup
```
Prototype scope note: ETAs, cost estimates, and the scripted run are
realistic fixture data — not live research. The trace stream replays a
deterministic Deep Report run with pause/resume/cancel controls; the
Reading Room's report, ledger, and exports are generated from the same
fixture. The v2 surface mockups (project graph, task inbox) ship in the
next phase.
Prototype scope note: ETAs, cost estimates, the scripted run, the stored
reports, connection edges, and adapter statuses are realistic fixture
data — not live research. The trace stream replays a deterministic Deep
Report run with pause/resume/cancel controls; the Reading Room's report,
ledger, and exports are generated from the same fixture. API-key entry,
live adapter probing, and full search land with the v0.2 backend.
+2
View File
@@ -3,6 +3,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Prototype runs entirely on mock data — no API routes, no network calls
// beyond static assets. Keep the surface minimal and deterministic.
// Standalone output for the Docker release image (ciagent release system).
output: "standalone",
};
export default nextConfig;
+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.0",
"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: {}
+10 -10
View File
@@ -1,17 +1,17 @@
import { AskForm } from "@/components/ask-form";
import { AskView } from "@/components/ask-view";
/**
* /ask (T-3.1, REQ-55) — the front door: one question box, one optional
* context box, one primary action. Generous whitespace, no competition.
* /ask (T-3.1, REQ-55; T-7.2/7.3, REQ-67/68) — the front door: one
* question box, one optional context box, one primary action. The
* opt-in affordances live below and beside the form — open-question
* suggestions and the refine dialogue — never competing with it.
* The app shell (sidebar + top bar) lives in the root layout.
*/
export default function AskPage() {
return (
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 py-section">
<div className="mx-auto flex max-w-3xl flex-col px-6 py-section">
<header className="mb-block">
<p className="text-caption font-medium tracking-wide text-accent uppercase">
ResearchX
</p>
<h1 className="mt-3 text-display font-semibold text-ink">
<h1 className="text-display font-semibold text-ink">
Ask a question.
<br />
Get a report you can trust.
@@ -23,11 +23,11 @@ export default function AskPage() {
</p>
</header>
<AskForm />
<AskView />
<p className="mt-block text-caption text-ink-faint">
Prototype: runs on scripted mock data — no API keys, no live search.
</p>
</main>
</div>
);
}
+1 -1
View File
@@ -11,4 +11,4 @@ import { api } from "@/lib/api/client";
export default async function ChoosePage() {
const methodologies = await api.getMethodologies();
return <ChooseView methodologies={methodologies} />;
}
}
+1
View File
@@ -50,6 +50,7 @@
--color-ink-soft: #4a4945; /* secondary text */
--color-ink-faint: #8a8880; /* muted metadata */
--color-line: #e5e2da; /* hairline borders */
--color-hairline: #e5e2da; /* border-hairline utility */
--color-accent: #1e5c3f; /* deep green — primary action */
--color-accent-strong: #164732; /* hover / pressed */
+21 -2
View File
@@ -1,5 +1,16 @@
import type { Metadata } from "next";
import "./globals.css";
import { AppSidebar } from "@/components/app-sidebar";
import { AppTopBar } from "@/components/app-top-bar";
/**
* Root layout (T-5.2, REQ-63) — the persistent app shell.
*
* Every screen renders inside the shell: left sidebar (nav + future
* slots) and top bar (⌘K palette + avatar). The shell adds zero
* required clicks to the happy path — it is pure wayfinding chrome.
* Page content lands in one <main> landmark per route (a11y).
*/
export const metadata: Metadata = {
title: "ResearchX",
@@ -14,7 +25,15 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body>{children}</body>
<body>
<div className="flex min-h-dvh">
<AppSidebar />
<div className="flex min-w-0 flex-1 flex-col">
<AppTopBar />
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
</div>
</div>
</body>
</html>
);
}
}
+3 -3
View File
@@ -7,8 +7,8 @@ export const metadata: Metadata = {
export default function LibraryPage() {
return (
<main className="mx-auto max-w-6xl px-6 py-section">
<div className="mx-auto max-w-6xl px-6 py-section">
<LibraryView />
</main>
</div>
);
}
}
+7 -11
View File
@@ -1,12 +1,11 @@
import { RunView } from "@/components/run-view";
/**
* /run/[id] (T-3.2b) — placeholder for the Plan & Run screen.
* /run/[id] (T-2.4, REQ-57) — the Plan & Run screen.
*
* Phase 2 ships the real screen (plan approval, live scripted trace,
* pause/cancel). For now: the run's question + methodology and a clear
* "ships in phase 2" notice, so the Start-research click-path lands
* somewhere honest.
* Server shell for the run stage: plan approval, the live scripted
* trace, and the terminal-state onward links. Run data flows through
* the ApiClient boundary inside RunView.
*/
export default async function RunPage({
params,
@@ -15,11 +14,8 @@ export default async function RunPage({
}) {
const { id } = await params;
return (
<main className="mx-auto max-w-3xl px-6 py-section">
<p className="text-caption font-medium tracking-wide text-accent uppercase">
ResearchX · Run
</p>
<div className="mx-auto max-w-3xl px-6 py-section">
<RunView id={id} />
</main>
</div>
);
}
}
+3 -3
View File
@@ -7,8 +7,8 @@ export default async function ReportPage({
}) {
const { id } = await params;
return (
<main className="mx-auto max-w-6xl px-6 py-section">
<div className="mx-auto max-w-6xl px-6 py-section">
<ReadingRoom id={id} />
</main>
</div>
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { SettingsView } from "@/components/settings-view";
export const metadata: Metadata = {
title: "Settings — ResearchX",
};
export default function SettingsPage() {
return (
<div className="mx-auto max-w-3xl px-6 py-section">
<SettingsView />
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* App-shell chrome tests (T-5.2, REQ-63, REQ-66).
*
* Sidebar: nav entries render, connection dots hydrate from the
* boundary. Top bar: ⌘K opens the palette, entries navigate, avatar
* opens the user menu. The shell adds zero required clicks to the
* happy path — that criterion stays pinned in three-click-path.test.
*/
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppSidebar } from "@/components/app-sidebar";
import { AppTopBar } from "@/components/app-top-bar";
import { CommandPalette } from "@/components/command-palette";
import { UserMenu } from "@/components/user-menu";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
usePathname: () => "/ask",
}));
afterEach(() => {
pushMock.mockClear();
cleanup();
});
describe("AppSidebar (REQ-63)", () => {
it("renders primary nav with the three entries", () => {
render(<AppSidebar />);
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeTruthy();
expect(screen.getByRole("link", { name: "New research" })).toHaveAttribute(
"href",
"/ask",
);
expect(screen.getByRole("link", { name: "Library" })).toHaveAttribute(
"href",
"/library",
);
expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute(
"href",
"/settings",
);
});
it("hydrates all five adapter connection dots", async () => {
render(<AppSidebar />);
const list = await screen.findByRole("list", {
name: "Adapter connection status",
});
expect(list).toBeTruthy();
for (const label of ["Model", "Search", "Storage", "Memory", "Secrets"]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});
it("marks the active nav entry with aria-current", () => {
render(<AppSidebar />);
expect(screen.getByRole("link", { name: "New research" })).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getByRole("link", { name: "Library" })).not.toHaveAttribute(
"aria-current",
);
});
});
describe("AppTopBar (REQ-63, REQ-66)", () => {
it("shows the search slot with its ⌘K affordance", () => {
render(<AppTopBar />);
expect(
screen.getByRole("button", { name: /Search or jump to/i }),
).toBeInTheDocument();
expect(screen.getByText("⌘K")).toBeInTheDocument();
});
it("⌘K opens the palette with screens + stored reports", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
await user.keyboard("{Meta>}k{/Meta}");
const dialog = await screen.findByRole("dialog", { name: "Search or jump to" });
expect(dialog).toBeTruthy();
// Screen entries
expect(screen.getByText("New research — Ask")).toBeInTheDocument();
expect(screen.getByText("Library")).toBeInTheDocument();
// Stored-report entries hydrate from the boundary
expect(
await screen.findByText("Solid-State Battery Advances: A Deep Report"),
).toBeInTheDocument();
});
it("avatar button opens the user menu with profile + links", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
await user.click(screen.getByRole("button", { name: "User menu" }));
const menu = await screen.findByRole("menu", { name: "User menu" });
expect(menu).toBeTruthy();
expect(screen.getByText("Researcher")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Settings" })).toHaveAttribute(
"href",
"/settings",
);
expect(
screen.getByRole("menuitem", { name: "About & version" }),
).toHaveAttribute("href", "/settings#about");
});
it("Escape closes the user menu", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
await user.click(screen.getByRole("button", { name: "User menu" }));
await screen.findByRole("menu", { name: "User menu" });
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu", { name: "User menu" })).toBeNull();
});
it("clicking the avatar again closes the menu (toggle, not reopen)", async () => {
const user = userEvent.setup();
render(<AppTopBar />);
const avatar = screen.getByRole("button", { name: "User menu" });
await user.click(avatar);
await screen.findByRole("menu", { name: "User menu" });
await user.click(avatar);
expect(screen.queryByRole("menu", { name: "User menu" })).toBeNull();
});
});
describe("CommandPalette (REQ-63)", () => {
const entries = [
{ kind: "screen" as const, label: "New research — Ask", href: "/ask" },
{ kind: "screen" as const, label: "Library", href: "/library" },
{
kind: "report" as const,
label: "Lithium Refining Capacity Through 2030",
href: "/run/run-materials/report",
},
];
it("filters entries by query", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<CommandPalette entries={entries} onNavigate={onNavigate} onDismiss={() => {}} />,
);
await user.type(screen.getByRole("textbox", { name: "Search or jump to" }), "lith");
expect(screen.getByText("Lithium Refining Capacity Through 2030")).toBeInTheDocument();
expect(screen.queryByText("New research — Ask")).toBeNull();
});
it("Enter navigates to the highlighted entry", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<CommandPalette entries={entries} onNavigate={onNavigate} onDismiss={() => {}} />,
);
const input = screen.getByRole("textbox", { name: "Search or jump to" });
await user.type(input, "library");
await user.keyboard("{Enter}");
expect(onNavigate).toHaveBeenCalledWith("/library");
});
it("shows an honest empty state", async () => {
const user = userEvent.setup();
render(
<CommandPalette entries={entries} onNavigate={() => {}} onDismiss={() => {}} />,
);
await user.type(
screen.getByRole("textbox", { name: "Search or jump to" }),
"zzzz",
);
expect(screen.getByText(/Nothing matches/i)).toBeInTheDocument();
});
});
describe("UserMenu (REQ-66)", () => {
it("renders the local profile from the boundary", async () => {
render(<UserMenu onNavigate={() => {}} onDismiss={() => {}} />);
expect(await screen.findByText("Researcher")).toBeInTheDocument();
expect(screen.getByText("Local workspace — single user")).toBeInTheDocument();
});
});
+134
View File
@@ -0,0 +1,134 @@
"use client";
/**
* AppSidebar (T-5.2, REQ-63) — the persistent left rail.
*
* Primary nav (New research, Library, Settings) + future-option slots:
* adapter connection-status dots (v0.2 preview) and honest preview
* pills where a slot is not yet functional. Server-renderable static
* structure; the connection dots hydrate from the ApiClient boundary.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { api, type ConnectionSlot } from "@/lib/api/client";
type SlotsState =
| { kind: "loading" }
| { kind: "error" }
| { kind: "ready"; slots: ConnectionSlot[] };
/** One nav entry — active state derives from the current pathname. */
function NavItem({
href,
label,
exact,
}: {
href: string;
label: string;
exact: boolean;
}) {
const pathname = usePathname() ?? "";
const active = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`block rounded-card px-3 py-2 text-small font-medium transition-colors ${
active
? "bg-accent-wash text-accent-ink"
: "text-ink-soft hover:bg-paper-sunken hover:text-ink"
}`}
>
{label}
</Link>
);
}
/** Status dot + label for one adapter slot (v0.2 preview). */
function ConnectionDot({ slot }: { slot: ConnectionSlot }) {
const dotColor =
slot.status === "connected"
? "bg-success"
: slot.status === "fallback"
? "bg-warn"
: "bg-ink-faint";
return (
<li className="flex items-center gap-2" title={slot.detail}>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-pill ${dotColor}`}
/>
<span className="text-caption text-ink-soft">{slot.label}</span>
{slot.status === "preview" && (
<span className="rounded-full border border-hairline px-1.5 text-caption text-ink-faint">
preview
</span>
)}
</li>
);
}
export function AppSidebar() {
const [slots, setSlots] = useState<SlotsState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
api
.getConnectionStatus()
.then((result) => {
if (!cancelled) setSlots({ kind: "ready", slots: result });
})
.catch(() => {
if (!cancelled) setSlots({ kind: "error" });
});
return () => {
cancelled = true;
};
}, []);
return (
<aside
aria-label="Primary"
className="flex w-60 shrink-0 flex-col gap-6 border-r border-hairline bg-paper-raised px-4 py-6"
>
<Link href="/ask" className="px-3">
<p className="text-small font-semibold tracking-wide text-ink">
ResearchX
</p>
<p className="text-caption text-ink-faint">Deep-research studio</p>
</Link>
<nav aria-label="Main navigation" className="flex flex-col gap-1">
<NavItem href="/ask" label="New research" exact />
<NavItem href="/library" label="Library" exact />
<NavItem href="/settings" label="Settings" exact />
</nav>
<div>
<p className="px-3 text-caption font-medium uppercase tracking-wide text-ink-faint">
Connections
</p>
{slots.kind === "ready" ? (
<ul className="mt-2 space-y-1.5" aria-label="Adapter connection status">
{slots.slots.map((slot) => (
<ConnectionDot key={slot.slot} slot={slot} />
))}
</ul>
) : (
<p className="mt-2 px-3 text-caption text-ink-faint">
{slots.kind === "loading" ? "Checking…" : "Status unavailable"}
</p>
)}
<p className="mt-2 px-3 text-caption text-ink-faint">
Adapter health lands in v0.2
</p>
</div>
<p className="mt-auto px-3 text-caption text-ink-faint">
v0.1 · prototype — mock data
</p>
</aside>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
/**
* AppTopBar (T-5.2, REQ-63, REQ-66) — the persistent top rail.
*
* Left: search/⌘K slot opening the navigation palette (screens + stored
* reports — navigation only; full search ships in v0.2). Right: avatar
* button opening the user menu (profile, Settings, About). All data
* flows through the ApiClient boundary; the menu is keyboard-accessible
* (Escape closes, focus ring per global standard).
*/
import { usePathname, useRouter } from "next/navigation";
import { forwardRef, useEffect, useRef, useState } from "react";
import {
api,
type ReportLibrary,
type UserProfile,
} from "@/lib/api/client";
import { CommandPalette, type PaletteEntry } from "@/components/command-palette";
import { UserMenu } from "@/components/user-menu";
export function AppTopBar() {
const router = useRouter();
const pathname = usePathname() ?? "";
const [paletteOpen, setPaletteOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [library, setLibrary] = useState<ReportLibrary | null>(null);
const avatarRef = useRef<HTMLButtonElement | null>(null);
// ⌘K / Ctrl-K opens the palette from anywhere in the app.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setPaletteOpen((open) => !open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Stored reports feed the palette's report section.
useEffect(() => {
let cancelled = false;
api
.getReportLibrary()
.then((result) => {
if (!cancelled) setLibrary(result);
})
.catch(() => {
/* palette falls back to screen entries when the library is unavailable */
});
return () => {
cancelled = true;
};
}, []);
const paletteEntries: PaletteEntry[] = [
{ kind: "screen", label: "New research — Ask", href: "/ask" },
{ kind: "screen", label: "Library", href: "/library" },
{ kind: "screen", label: "Settings", href: "/settings" },
...(library?.reports ?? []).map((report) => ({
kind: "report" as const,
label: report.title,
href: `/run/${report.runId}/report`,
})),
];
return (
<header className="sticky top-0 z-20 flex items-center gap-4 border-b border-hairline bg-paper-raised/95 px-6 py-3 backdrop-blur">
<p className="text-caption font-medium uppercase tracking-wide text-ink-faint">
{pathLabel(pathname)}
</p>
<div className="ml-auto flex items-center gap-3">
<button
type="button"
onClick={() => setPaletteOpen(true)}
className="flex items-center gap-2 rounded-card border border-hairline bg-paper-sunken px-3 py-1.5 text-caption text-ink-faint transition-colors hover:border-ink-faint"
aria-label="Search or jump to — Command K"
>
<span aria-hidden="true">⌕</span>
<span>Search or jump to…</span>
<kbd className="rounded border border-hairline bg-paper-raised px-1.5 font-sans text-caption text-ink-faint">
⌘K
</kbd>
</button>
<AvatarButton
ref={avatarRef}
menuOpen={menuOpen}
onToggle={() => setMenuOpen((v) => !v)}
/>
</div>
{menuOpen && (
<UserMenu
toggleRef={avatarRef}
onNavigate={() => setMenuOpen(false)}
onDismiss={() => setMenuOpen(false)}
/>
)}
{paletteOpen && (
<CommandPalette
entries={paletteEntries}
onNavigate={(href) => {
setPaletteOpen(false);
router.push(href);
}}
onDismiss={() => setPaletteOpen(false)}
/>
)}
</header>
);
}
/** The avatar disc — initials from the profile fixture. */
const AvatarButton = forwardRef<HTMLButtonElement, {
menuOpen: boolean;
onToggle: () => void;
}>(function AvatarButton({ menuOpen, onToggle }, ref) {
const [profile, setProfile] = useState<UserProfile | null>(null);
useEffect(() => {
let cancelled = false;
api
.getUserProfile()
.then((result) => {
if (!cancelled) setProfile(result);
})
.catch(() => {
/* menu renders a fallback profile when unavailable */
});
return () => {
cancelled = true;
};
}, []);
return (
<button
ref={ref}
type="button"
onClick={onToggle}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label="User menu"
className="flex h-9 w-9 items-center justify-center rounded-pill bg-accent text-small font-semibold text-paper-raised transition-colors hover:bg-accent-strong"
>
{profile?.initials ?? "··"}
</button>
);
});
/** Screen label shown top-left — quiet wayfinding, not a breadcrumb. */
function pathLabel(pathname: string): string {
if (pathname === "/ask") return "New research";
if (pathname.startsWith("/choose")) return "Choose methodology";
if (pathname === "/library") return "Library";
if (pathname === "/settings") return "Settings";
const runMatch = pathname.match(/^\/run\/([^/]+)/);
if (runMatch) {
return pathname.endsWith("/report") ? "Reading room" : "Plan & run";
}
return "ResearchX";
}
-82
View File
@@ -1,82 +0,0 @@
/**
* AskForm tests (T-3.3, REQ-55).
*
* - Button disabled until the question is non-empty
* - Submit stores the payload in the ask store (state carry) and
* routes to /choose
*/
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AskForm } from "@/components/ask-form";
import { clearAskState, getAskState } from "@/lib/ask-store";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
describe("AskForm", () => {
beforeEach(() => {
pushMock.mockClear();
clearAskState();
});
afterEach(cleanup);
it("disables the submit button until a question is typed", async () => {
const user = userEvent.setup();
render(<AskForm />);
const button = screen.getByRole("button", { name: "Research this" });
expect(button).toBeDisabled();
const question = screen.getByLabelText(/what do you want to know\?/i);
await user.type(question, "What are the latest advances in solid-state batteries?");
expect(button).toBeEnabled();
});
it("submits: stores question + context and routes to /choose", async () => {
const user = userEvent.setup();
render(<AskForm />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries?",
);
await user.type(
screen.getByLabelText(/anything else it should know\?/i),
"Focus on automotive applications",
);
await user.click(screen.getByRole("button", { name: "Research this" }));
// State carry (UX criterion): the payload survives navigation.
const carried = getAskState();
expect(carried?.question).toBe(
"What are the latest advances in solid-state batteries?",
);
expect(carried?.context).toBe("Focus on automotive applications");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/choose");
});
});
it("submits without storing an empty context", async () => {
const user = userEvent.setup();
render(<AskForm />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"Who makes solid-state cells today?",
);
await user.click(screen.getByRole("button", { name: "Research this" }));
const carried = getAskState();
expect(carried?.question).toBe("Who makes solid-state cells today?");
expect(carried?.context).toBeUndefined();
});
});
-78
View File
@@ -1,78 +0,0 @@
"use client";
/**
* AskForm (T-3.1, REQ-55) — the front door.
*
* Exactly ONE question textarea + ONE optional context textarea + one
* primary action. Nothing else competes for attention. Submit stores
* the payload in the ask store and routes to /choose.
*/
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { FormEvent } from "react";
import { setAskState } from "@/lib/ask-store";
export function AskForm() {
const router = useRouter();
const [question, setQuestion] = useState("");
const [context, setContext] = useState("");
const [submitting, setSubmitting] = useState(false);
const canSubmit = question.trim().length > 0 && !submitting;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setAskState({
question: question.trim(),
...(context.trim() !== "" ? { context: context.trim() } : {}),
});
router.push("/choose");
}
return (
<form onSubmit={handleSubmit} className="measure-narrow">
<label htmlFor="question" className="block text-body font-medium">
What do you want to know?
</label>
<textarea
id="question"
name="question"
value={question}
onChange={(e) => setQuestion(e.target.value)}
rows={3}
required
autoFocus
placeholder="e.g. What are the latest advances in solid-state batteries?"
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
/>
<label
htmlFor="context"
className="mt-6 block text-small font-medium text-ink-soft"
>
Anything else it should know?{" "}
<span className="text-ink-faint">(optional)</span>
</label>
<textarea
id="context"
name="context"
value={context}
onChange={(e) => setContext(e.target.value)}
rows={2}
placeholder="Constraints, prior findings, the decision this feeds…"
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-small text-ink placeholder:text-ink-faint"
/>
<button
type="submit"
disabled={!canSubmit}
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "Opening methodologies…" : "Research this"}
</button>
</form>
);
}
@@ -0,0 +1,88 @@
"use client";
/**
* AskSuggestions (T-7.2, REQ-67) — open questions worth following up.
*
* Follow-up query suggestions derived from unanswered questions of
* past runs, behind the ApiClient boundary. Clicking a card fills the
* question box (editable — never auto-submits). Sits below the primary
* form action; the honest empty state explains how items collect here.
*/
import { useEffect, useState } from "react";
import { api, type SuggestedQuery } from "@/lib/api/client";
type SuggestionsState =
| { kind: "loading" }
| { kind: "error" }
| { kind: "ready"; suggestions: SuggestedQuery[] };
export function AskSuggestions({ onPick }: { onPick: (question: string) => void }) {
const [state, setState] = useState<SuggestionsState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
api
.getSuggestedQueries()
.then((suggestions) => {
if (!cancelled) setState({ kind: "ready", suggestions });
})
.catch(() => {
if (!cancelled) setState({ kind: "error" });
});
return () => {
cancelled = true;
};
}, []);
return (
<section
aria-label="Open questions from your research"
className="mt-block"
>
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
Pick up an open question
</h2>
{state.kind === "loading" && (
<p className="mt-2 text-caption text-ink-faint">Checking your research…</p>
)}
{state.kind === "error" && (
<p className="mt-2 text-caption text-ink-faint">
Suggestions unavailable right now.
</p>
)}
{state.kind === "ready" && state.suggestions.length === 0 && (
<p className="measure-reading mt-2 text-caption text-ink-faint">
No open questions yet — they collect here as you run research.
</p>
)}
{state.kind === "ready" && state.suggestions.length > 0 && (
<>
<p className="mt-1 text-caption text-ink-soft">
Unanswered questions from your past runs — one click loads them
here for editing.
</p>
<ul className="mt-3 space-y-2">
{state.suggestions.map((suggestion) => (
<li key={suggestion.id}>
<button
type="button"
onClick={() => onPick(suggestion.question)}
className="w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-left transition-colors hover:border-ink-faint"
>
<p className="measure-reading text-small text-ink">
{suggestion.question}
</p>
<p className="mt-1 text-caption text-ink-faint">
From your research — {suggestion.sourceReportTitle}
</p>
</button>
</li>
))}
</ul>
</>
)}
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More