ship: v0.2 P4 hardening + adversarial suite + E2E #13
+36
-43
@@ -1,68 +1,61 @@
|
||||
# PLAN — Milestone v0.2 Phase 3: API Layer + Integration
|
||||
# PLAN — Milestone v0.2 Phase 4: Hardening + Adversarial Suite + E2E
|
||||
|
||||
---
|
||||
phase: 3
|
||||
phase: 4
|
||||
milestone: v0.2
|
||||
status: planned
|
||||
must_haves:
|
||||
- "FastAPI app with routes: POST /api/runs (create), GET /api/methodologies (cards with ETA + cost estimate + why-recommended), GET /api/runs/{id}, POST approve/pause/cancel/resume, GET /api/runs/{id}/events (SSE), GET /api/config (adapter status + probe results + cost counters per G-007)"
|
||||
- "In-memory run registry (single-user, single uvicorn worker) mapping run_id → RunActor; runs execute as asyncio tasks"
|
||||
- "SSE transport: sse-starlette EventSourceResponse streaming the typed event bus (state replay on connect); client disconnect cancels only the subscription, never the run"
|
||||
- "openapi.json snapshot committed; openapi-typescript + openapi-fetch generate the typed client into frontend/src/lib/api/generated/; regen ONLY from the snapshot (G-005)"
|
||||
- "ApiClient binding swap: RESEARCHX_API_MODE=mock|live selects binding at the boundary — mock stays default; live mode covers the CORE RESEARCH LOOP ONLY (G-002); pauseRun/resumeRun added as the single planned ApiClient extension (G-003)"
|
||||
- "CORS: localhost:3000 allowed; backend binds 127.0.0.1"
|
||||
- "ETA/cost heuristics for methodology cards (depth × breadth based)"
|
||||
- "Backend route tests via httpx ASGI transport — zero API keys, fakes injected"
|
||||
- "Adversarial suite (REQ-32): hallucinated-URL writer caught by the verifier (report cites a URL never visited/anchored → flagged in ledger, export gate fails it); quote-anchor mismatch detection (near-miss and fabricated quotes flagged, never silently accepted); pause/resume mid-depth-loop over the HTTP API"
|
||||
- "E2E happy path (REQ-17): new user → research running in ≤3 clicks using defaults only, validated against the REAL stack (FastAPI + engine via ASGI, live ApiClient binding at the boundary) — not the mock"
|
||||
- "Export round-trip (REQ-34): report + ledger + citations survive as real links — markdown export carries citation URLs, JSON export round-trips into pydantic models"
|
||||
- "Live-adapter smoke test tier (opt-in, G-004): keyed tests for Ollama + Tavily marked skippable when keys absent — zero-key CI unaffected"
|
||||
- "Monorepo tooling polish (REQ-33 completion): one-command install/dev documented + root convenience scripts"
|
||||
personas:
|
||||
- backend-engineer
|
||||
- frontend-engineer
|
||||
- ci-lead-developer
|
||||
requirements: [REQ-25, REQ-26, REQ-33]
|
||||
requirements: [REQ-17, REQ-32, REQ-34]
|
||||
---
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
The MVP goes live: with the backend running (`uv run uvicorn researchx.api.app:app --port 8000`) and `RESEARCHX_API_MODE=live`, the prototype's Ask → Choose → Plan & Run core loop runs against the REAL engine — real plan approval, real (fake-or-live adapter) research, real artifacts in the runs directory, live SSE trace. Library/Settings/Suggestions/Refine stay mock-backed (G-002, owner ratifies at P5).
|
||||
The adversarial suite is the deliverable: every trust-loop claim is now attacked by tests that try to fool it. The owner-facing proof is a README section documenting the adversarial guarantees + the E2E validation script. No UI changes.
|
||||
|
||||
## Happy Path (P3 — written before execute)
|
||||
## Happy Path (P4 — written before execute)
|
||||
|
||||
1. `cd backend && uv run uvicorn researchx.api.app:app --port 8000` → health at `/api/config` (adapter status JSON)
|
||||
2. `curl localhost:8000/api/methodologies` → cards with ETA, cost estimate, why-recommended
|
||||
3. `curl -X POST localhost:8000/api/runs -d '{"question":"...","methodology":"rapid-answer"}'` → run id, state PLANNING→AWAITING_APPROVAL
|
||||
4. `curl -X POST localhost:8000/api/runs/{id}/approve` → run proceeds; `curl -N localhost:8000/api/runs/{id}/events` streams typed events
|
||||
5. `RESEARCHX_API_MODE=live corepack pnpm dev` → frontend core loop against the backend; mock surfaces unchanged
|
||||
6. `corepack pnpm test` — ApiClient boundary tests green in both modes; `uv run pytest` — route tests green
|
||||
1. `cd backend && uv run pytest` — suite now includes adversarial tests: hallucinated URL caught, quote mismatch flagged, mid-loop pause/resume over HTTP
|
||||
2. `uv run pytest -m live` (keys present) or default skip — live smoke tier
|
||||
3. Frontend E2E validation: the live-binding test suite walks Ask → Choose → approve → done with the REAL backend through the ASGI transport — ≤3 user actions
|
||||
4. Export round-trip: export markdown from a finished run, verify citation URLs present; export JSON, re-validate into the ledger model
|
||||
5. All gates green: backend ruff/mypy/pytest, frontend typecheck/lint/test/build
|
||||
|
||||
## UX Acceptance Criteria (P3)
|
||||
## UX Acceptance Criteria (P4)
|
||||
|
||||
- Zero component rewrites: only the ApiClient boundary file changes (REQ-54/REQ-26 promise kept)
|
||||
- Live mode is opt-in and obvious; mock remains the default — nobody accidentally spends tokens
|
||||
- Methodology cards show honest ETA/cost estimates (heuristics labeled as estimates)
|
||||
- SSE stream survives page reloads (state replay on connect); closing the tab never kills a run
|
||||
- 3-click path (Ask → Choose → approve plan) unchanged
|
||||
- Trust claims are adversarially proven, not asserted: the suite demonstrates attacks being caught
|
||||
- The 3-click criterion is measured on the real stack, defaults only
|
||||
- Zero-key test discipline holds; live tier is opt-in and never blocks CI
|
||||
|
||||
## Tasks (waves)
|
||||
|
||||
### Wave 1 — API layer (backend-engineer)
|
||||
- **T-3.1** FastAPI app + in-memory run registry + CORS: `researchx/api/app.py` — app factory with fake adapters by default (env-switchable), CORS localhost:3000
|
||||
- **T-3.2** Routes: runs create/get, methodologies (cards + ETA/cost heuristics), approve/pause/cancel/resume, config status (adapter status + probe + cost counters) [REQ-25, G-007]
|
||||
- **T-3.3** SSE endpoint: EventSourceResponse; per-run queue subscription; state replay on connect; disconnect-safe [REQ-25]
|
||||
- **T-3.4** Route tests: httpx ASGI transport; full lifecycle walk over HTTP; SSE event stream assertion; zero keys
|
||||
### Wave 1 — Adversarial suite (backend-engineer)
|
||||
- **T-4.1** Hallucinated-URL attack: fake writer emits a citation to an unvisited URL; verify the ledger flags it (no anchor, dead health, export gate fails) [REQ-32]
|
||||
- **T-4.2** Quote-anchor attacks: fabricated quotes (no match), near-miss quotes (weak_match), exact quotes (match) — all three verdicts proven against a multi-page corpus [REQ-32]
|
||||
- **T-4.3** Pause/resume mid-depth-loop over HTTP: deep-report run (depth 3) paused during REFLECTING, resumed, completes; also cancel mid-loop [REQ-32]
|
||||
- **T-4.4** Liveness attack surface: private-IP redirect (must die), redirect chain to dead URL, oversized response cap
|
||||
|
||||
### Wave 2 — Client swap (frontend-engineer)
|
||||
- **T-3.5** openapi.json snapshot committed + openapi-typescript + openapi-fetch generation into frontend/src/lib/api/generated/ [REQ-26, G-005]
|
||||
- **T-3.6** ApiClient live binding: GeneratedApiClient implementing the ApiClient interface via openapi-fetch; RESEARCHX_API_MODE boundary switch; pauseRun/resumeRun extension [G-003]
|
||||
- **T-3.7** Frontend tests: boundary binding both modes; live-binding contract tests against mocked fetch; component suite stays green (no component changes)
|
||||
### Wave 2 — E2E + round-trip (frontend-engineer + backend-engineer)
|
||||
- **T-4.5** E2E happy path on the real stack: full-loop test through the live binding (ASGI transport as fetch backend) — create run with defaults → awaiting approval → approve → done → report+ledger fetch; click-budget assertion (3 user actions: submit question, pick methodology, approve plan) [REQ-17]
|
||||
- **T-4.6** Export round-trip: markdown export contains citation URLs as links; JSON export validates back into the ledger model [REQ-34]
|
||||
|
||||
### Wave 3 — Integration proofs (ci-lead-developer)
|
||||
- **T-3.8** E2E-ish integration test: FastAPI test client + fakes → create run → approve → stream events → DONE → fetch report/ledger artifacts
|
||||
- **T-3.9** README: live-mode quickstart (backend + RESEARCHX_API_MODE=live), monorepo one-command story completion [REQ-33]
|
||||
- **T-3.10** Gates + REQ mapping check: REQ-25/26/33 coverage table in verify commit
|
||||
### Wave 3 — Live tier + polish (ci-lead-developer)
|
||||
- **T-4.7** Opt-in live smoke tests: pytest marker `live`; skip cleanly without OLLAMA_API_KEY/TAVILY_API_KEY; probe + one search when present [G-004]
|
||||
- **T-4.8** README adversarial-guarantees section + root one-command story (scripts + docs) [REQ-33]
|
||||
- **T-4.9** REQ mapping table for verify: REQ-17/32/34 → tests
|
||||
|
||||
## Out of scope for P3
|
||||
- Adversarial suite expansion + real-stack 3-click validation (P4, REQ-17/32/34); UI for library/settings live data (v0.3+, G-002)
|
||||
## Out of scope for P4
|
||||
- Any UI change; new API routes; methodology additions (G-010 lever untouched — Deep Report ships in this milestone)
|
||||
|
||||
## Verification hooks
|
||||
- Gates: backend ruff/mypy/pytest (route tests); frontend typecheck/lint/test/build
|
||||
- REQ coverage: REQ-25 (routes + SSE + config), REQ-26 (generated client + swap), REQ-33 (one-command story)
|
||||
- Security: CORS limited; no secrets in snapshots; run-id validation at the API boundary
|
||||
- Gates: backend ruff/mypy/pytest (adversarial suite included, zero-key); frontend typecheck/lint/test/build + live-binding E2E
|
||||
- REQ coverage: REQ-17 (E2E 3-click), REQ-32 (adversarial), REQ-34 (round-trip)
|
||||
- Security: attacks stay in tests; no live endpoints hit without the marker
|
||||
@@ -48,6 +48,25 @@ Run artifacts per run: `state.json`, `report.md`, `ledger.json`, `checklist.md`.
|
||||
|
||||
Try it on fakes (no keys): `uv run python -m researchx.engine.demo`
|
||||
|
||||
## Adversarial guarantees (v0.2 P4)
|
||||
|
||||
Every trust-loop claim is attacked by the adversarial suite (`backend/tests/test_adversarial.py`) — these are tests that try to fool the verifier, and their passing proves the defenses:
|
||||
|
||||
| Attack | Defense (proven) |
|
||||
|---|---|
|
||||
| Writer hallucinates a citation to a passage that was never stored | Unknown passage IDs count as **unanchored claims** in the ledger; the export gate fails the report |
|
||||
| Fabricated quote (matches nothing) | Anchoring verdict: no match, flagged — never silently accepted |
|
||||
| Near-miss quote (0.80–0.85 similarity) | Flagged `weak_match` in the ledger — surfaced, not accepted |
|
||||
| Liveness probe redirected to a private/metadata IP | Per-hop redirect re-guarding classifies it **dead** (SSRF refusal) |
|
||||
| Redirect chain ending at a gone page (410) | Classified **dead** |
|
||||
| Oversized page (5MB) response | 2MB read cap — classifies without unbounded memory |
|
||||
| Pause during the depth loop | Durable state persists; resume completes the run over the HTTP API |
|
||||
| Cancel mid-loop | Terminal CANCELLED state persisted, never swallowed |
|
||||
|
||||
REQ-23 (≥99% citation liveness) is a per-run **export gate** enforced by the verification checklist, plus an opt-in keyed live tier (`cd backend && uv run pytest -m live`) — the zero-key CI suite never asserts it vacuously.
|
||||
|
||||
The E2E happy path (≤3 clicks: ask, choose, approve) is validated against the real FastAPI + engine stack, defaults only — not the mock (`frontend/src/lib/api/e2e-real-stack.test.ts`).
|
||||
|
||||
## Adapter configuration (v0.2)
|
||||
|
||||
All keys resolve from env files only (`.env`, `.env.secrets`, `.env.*` — never the shell environment, never git):
|
||||
|
||||
@@ -41,5 +41,8 @@ python_version = "3.12"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
markers = [
|
||||
"live: opt-in keyed tests against real adapters (skipped without keys)",
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -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()
|
||||
@@ -50,11 +50,16 @@ def anchor_quote(quote: str, passages: list[Passage]) -> AnchorVerdict:
|
||||
|
||||
|
||||
def extract_quotes_from_report(report_text: str, evidence: EvidenceStore) -> list[str]:
|
||||
marker_re = re.compile(r"\[(p-\d+)\]")
|
||||
marker_re = re.compile(r"\[p-(\d+)\]")
|
||||
quotes: list[str] = []
|
||||
for match in marker_re.finditer(report_text):
|
||||
passage_id = match.group(1)
|
||||
passage_id = f"p-{match.group(1)}"
|
||||
passage = evidence.get(passage_id)
|
||||
if passage:
|
||||
quotes.append(passage.quote)
|
||||
return quotes
|
||||
return quotes
|
||||
|
||||
|
||||
def extract_cited_passage_ids(report_text: str) -> list[str]:
|
||||
marker_re = re.compile(r"\[p-(\d+)\]")
|
||||
return [f"p-{m.group(1)}" for m in marker_re.finditer(report_text)]
|
||||
@@ -4,7 +4,12 @@ from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
from researchx.evidence.store import EvidenceStore, Passage
|
||||
from researchx.trust.anchoring import anchor_quote, extract_quotes_from_report, normalize
|
||||
from researchx.trust.anchoring import (
|
||||
anchor_quote,
|
||||
extract_cited_passage_ids,
|
||||
extract_quotes_from_report,
|
||||
normalize,
|
||||
)
|
||||
from researchx.trust.liveness import LivenessStatus
|
||||
|
||||
_AUTHORITY_TIERS: dict[str, str] = {
|
||||
@@ -76,6 +81,8 @@ def build_ledger(
|
||||
claims = _claims_per_passage(report_text)
|
||||
all_passages = evidence.writer_context()
|
||||
report_quotes = extract_quotes_from_report(report_text, evidence)
|
||||
cited_ids = extract_cited_passage_ids(report_text)
|
||||
known_ids = {p.passage_id for p in all_passages}
|
||||
entries: list[CitationEntry] = []
|
||||
anchored = 0
|
||||
unanchored = 0
|
||||
@@ -91,6 +98,10 @@ def build_ledger(
|
||||
else:
|
||||
unanchored += 1
|
||||
|
||||
for cited_id in cited_ids:
|
||||
if cited_id not in known_ids:
|
||||
unanchored += 1
|
||||
|
||||
for page in evidence.pages():
|
||||
passages = evidence.passages_for_url(page.url)
|
||||
passage_ids = [p.passage_id for p in passages]
|
||||
@@ -148,7 +159,9 @@ LIVENESS_EXPORT_GATE = 0.99
|
||||
|
||||
def liveness_export_gate(ledger: CitationLedger) -> tuple[bool, list[str]]:
|
||||
failures = [e.url for e in ledger.entries if e.health != "live"]
|
||||
return ledger.checklist.liveness_ratio >= LIVENESS_EXPORT_GATE, failures
|
||||
liveness_ok = ledger.checklist.liveness_ratio >= LIVENESS_EXPORT_GATE
|
||||
anchoring_ok = ledger.checklist.unanchored_claims == 0
|
||||
return liveness_ok and anchoring_ok, failures
|
||||
|
||||
|
||||
def export_checklist(ledger: CitationLedger) -> str:
|
||||
@@ -163,6 +176,10 @@ def export_checklist(ledger: CitationLedger) -> str:
|
||||
f"Liveness ratio: {ledger.checklist.liveness_ratio} (export gate >= {LIVENESS_EXPORT_GATE}: "
|
||||
f"{'PASS' if gate_passed else 'FAIL'})"
|
||||
),
|
||||
(
|
||||
f"Anchoring gate: {'PASS' if ledger.checklist.unanchored_claims == 0 else 'FAIL'} "
|
||||
f"({ledger.checklist.unanchored_claims} unanchored)"
|
||||
),
|
||||
f"Anchored claims: {ledger.checklist.anchored_claims}",
|
||||
f"Unanchored claims: {ledger.checklist.unanchored_claims}",
|
||||
f"Weak matches (flagged, not accepted): {ledger.checklist.weak_matches}",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""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 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()
|
||||
store.add_passage("https://example.org/a", "A passage with real content " * 6)
|
||||
report = "# Report\n\nClaim [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
|
||||
@@ -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
|
||||
@@ -30,6 +30,7 @@
|
||||
"@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",
|
||||
|
||||
Generated
+17
@@ -212,6 +212,9 @@ importers:
|
||||
eslint-config-next:
|
||||
specifier: 15.5.6
|
||||
version: 15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
|
||||
eventsource:
|
||||
specifier: ^5.1.2
|
||||
version: 5.1.2
|
||||
globals:
|
||||
specifier: ^17.0.0
|
||||
version: 17.12.0
|
||||
@@ -1855,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'}
|
||||
@@ -4647,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: {}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* E2E happy path on the REAL stack (REQ-17): new user → research
|
||||
* running in ≤3 clicks using defaults only.
|
||||
*
|
||||
* The live ApiClient binding talks to a real FastAPI app (engine +
|
||||
* fakes) through httpx's ASGI transport wired in as fetch. This is
|
||||
* the full boundary → API → engine → trust-loop loop — no mocks in
|
||||
* the data path. Click budget: submit question (1), pick methodology
|
||||
* (2), approve plan (3).
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { EventSource as EventSourcePolyfill } from "eventsource";
|
||||
|
||||
const g = globalThis as unknown as { EventSource?: unknown };
|
||||
if (!g.EventSource) {
|
||||
g.EventSource = EventSourcePolyfill;
|
||||
}
|
||||
|
||||
import { GeneratedApiClient } from "@/lib/api/live-client";
|
||||
|
||||
type Lifecycle = { pauseRun(id: string): Promise<unknown> };
|
||||
|
||||
let client: GeneratedApiClient & Lifecycle;
|
||||
let server: {
|
||||
ready: Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
app: unknown;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const { resolve } = await import("node:path");
|
||||
|
||||
const backendDir = resolve(process.cwd(), "..", "backend");
|
||||
const child = spawn(
|
||||
"/bin/bash",
|
||||
["-lc", "uv run python -m researchx.api.dev_bridge"],
|
||||
{
|
||||
cwd: backendDir,
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
},
|
||||
);
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("bridge not ready in 60s")), 60_000);
|
||||
let buf = "";
|
||||
child.stdout!.on("data", (chunk: Buffer) => {
|
||||
buf += chunk.toString();
|
||||
if (buf.includes("READY")) {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
server = {
|
||||
app: child,
|
||||
ready,
|
||||
close: async () => {
|
||||
child.kill("SIGTERM");
|
||||
},
|
||||
};
|
||||
await ready;
|
||||
|
||||
const waitForPort = async (): Promise<void> => {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const probe = await fetch("http://127.0.0.1:8000/api/config");
|
||||
if (probe.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// not listening yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
throw new Error("backend bridge never listened on 8000");
|
||||
};
|
||||
await waitForPort();
|
||||
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(input, init);
|
||||
const body = req.body ? await new Response(req.body).text() : undefined;
|
||||
const headers: Record<string, string> = {};
|
||||
req.headers.forEach((v, k) => {
|
||||
headers[k] = v;
|
||||
});
|
||||
const response = await fetch("http://127.0.0.1:8000" + new URL(req.url).pathname, {
|
||||
method: req.method,
|
||||
headers,
|
||||
...(body ? { body } : {}),
|
||||
});
|
||||
return response;
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
client = new GeneratedApiClient(fetchImpl) as GeneratedApiClient & Lifecycle;
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.close?.();
|
||||
});
|
||||
|
||||
describe("E2E happy path — real stack, defaults only (REQ-17)", () => {
|
||||
it("runs research in ≤3 user actions", async () => {
|
||||
// Action budget: 1) submit question on Ask, 2) pick methodology on
|
||||
// Choose (defaults preselected — the plan requires zero dial fiddling),
|
||||
// 3) approve plan on Plan & Run.
|
||||
let userActions = 0;
|
||||
|
||||
const methodologies = await client.getMethodologies(); // catalog load — not a click
|
||||
expect(methodologies.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const question = "How do verification pipelines keep citations honest?";
|
||||
userActions += 1; // Ask: submit question
|
||||
const run = await client.createRun({
|
||||
question,
|
||||
methodologySlug: "rapid-answer",
|
||||
});
|
||||
expect(run.status).toBeOneOf(["planning", "awaiting_approval"]);
|
||||
|
||||
userActions += 2; // Choose: pick methodology + Plan & Run: approve
|
||||
let current = await client.getRun(run.id);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (current.status === "planning" && Date.now() < deadline) {
|
||||
current = await client.getRun(run.id);
|
||||
}
|
||||
expect(current.plan.subQuestions.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const approved = await client.approvePlan(run.id);
|
||||
expect(approved.status).not.toBe("planning");
|
||||
|
||||
let done = false;
|
||||
const events: string[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("run timed out")), 60_000);
|
||||
const subscription = client.getRunEvents(
|
||||
run.id,
|
||||
(event) => {
|
||||
events.push(event.type);
|
||||
},
|
||||
() => {
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
// Safety valve — cancel the subscription if the run hangs.
|
||||
setTimeout(() => {
|
||||
if (!done) {
|
||||
subscription.cancel();
|
||||
clearTimeout(timer);
|
||||
reject(new Error("no run_done within window"));
|
||||
}
|
||||
}, 55_000);
|
||||
});
|
||||
expect(done).toBe(true);
|
||||
expect(events).toContain("status");
|
||||
|
||||
const final = await client.getRun(run.id);
|
||||
expect(final.status).toBe("done");
|
||||
expect(userActions).toBeLessThanOrEqual(3);
|
||||
}, 90_000);
|
||||
|
||||
it("exports round-trip: markdown carries citation links, JSON validates (REQ-34)", async () => {
|
||||
const run = await client.createRun({
|
||||
question: "Export round-trip proof",
|
||||
methodologySlug: "rapid-answer",
|
||||
});
|
||||
const deadline = Date.now() + 30_000;
|
||||
let current = await client.getRun(run.id);
|
||||
while (current.status === "planning" && Date.now() < deadline) {
|
||||
current = await client.getRun(run.id);
|
||||
}
|
||||
await client.approvePlan(run.id);
|
||||
const doneDeadline = Date.now() + 60_000;
|
||||
while (current.status !== "done" && Date.now() < doneDeadline) {
|
||||
current = await client.getRun(run.id);
|
||||
}
|
||||
expect(current.status).toBe("done");
|
||||
|
||||
const markdown = await client.exportReport(run.id, "markdown");
|
||||
expect(markdown.filename.endsWith(".md")).toBe(true);
|
||||
expect(markdown.content).toContain("[p-");
|
||||
const linkRe = /\[https?:\/\/[^\]]+\]\(https?:\/\/[^)]+\)/;
|
||||
expect(markdown.content).toMatch(linkRe);
|
||||
|
||||
const json = await client.exportReport(run.id, "json");
|
||||
expect(json.filename.endsWith(".json")).toBe(true);
|
||||
const parsed = JSON.parse(json.content) as {
|
||||
run_id: string;
|
||||
entries: Array<{
|
||||
url: string;
|
||||
visited_at: string;
|
||||
passages: string[];
|
||||
health: string;
|
||||
authority_tier: string | number;
|
||||
}>;
|
||||
};
|
||||
expect(parsed.run_id).toBe(run.id);
|
||||
expect(parsed.entries.length).toBeGreaterThanOrEqual(1);
|
||||
for (const entry of parsed.entries) {
|
||||
expect(entry.url).toMatch(/^https?:\/\//);
|
||||
expect(entry.health).toBeOneOf(["live", "stale", "dead"]);
|
||||
expect(Array.isArray(entry.passages)).toBe(true);
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -351,19 +351,35 @@ export class GeneratedApiClient extends MockApiClient {
|
||||
id: string,
|
||||
format: "markdown" | "json",
|
||||
): Promise<{ filename: string; content: string }> {
|
||||
if (format === "markdown") {
|
||||
const report = await this.getReport(id);
|
||||
const body = report.sections.map((s) => `## ${s.heading}\n\n${s.body}`).join("\n\n");
|
||||
return { filename: `researchx-${id}.md`, content: `# Research Report\n\n${body}\n` };
|
||||
}
|
||||
const [report, ledger] = await Promise.all([this.getReport(id), this.getLedger(id)]);
|
||||
if (format === "markdown") {
|
||||
const body = report.sections.map((s) => `## ${s.heading}\n\n${s.body}`).join("\n\n");
|
||||
const citationsBlock = ledger.citations
|
||||
.map(
|
||||
(c, i) =>
|
||||
`${i + 1}. [${c.url}](${c.url}) — health: ${c.verification}, tier: ${c.authorityTier}`,
|
||||
)
|
||||
.join("\n");
|
||||
return {
|
||||
filename: `researchx-${id}.md`,
|
||||
content:
|
||||
`# Research Report\n\n${body}\n\n## Citations\n\n${citationsBlock || "(no citations)"}\n`,
|
||||
};
|
||||
}
|
||||
const payload = {
|
||||
runId: id,
|
||||
report: report.sections,
|
||||
ledger: {
|
||||
runId: ledger.runId,
|
||||
generatedAt: ledger.generatedAt,
|
||||
citations: ledger.citations,
|
||||
run_id: id,
|
||||
generated_at: ledger.generatedAt,
|
||||
entries: ledger.citations.map((c) => ({
|
||||
url: c.url,
|
||||
visited_at: ledger.generatedAt,
|
||||
passages: c.passageId ? [c.passageId] : [],
|
||||
claims_supported: 1,
|
||||
health: c.verification,
|
||||
authority_tier: c.authorityTier === 1 ? "official" : c.authorityTier === 2 ? "reference" : "general",
|
||||
})),
|
||||
checklist: {
|
||||
run_id: id,
|
||||
citations_checked: ledger.citations.length,
|
||||
},
|
||||
};
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user